diff --git a/api/.agents/skills/sse/SKILL.md b/api/.agents/skills/sse/SKILL.md index 0879c9d..20ebe5b 100644 --- a/api/.agents/skills/sse/SKILL.md +++ b/api/.agents/skills/sse/SKILL.md @@ -60,9 +60,13 @@ emissor é removido). atrasa quem chama `emitir` (hoje, o consumidor RabbitMQ de T-044/T-045). Aceitável para o volume do MVP; se isso doer, a saída é emitir numa thread separada da que consome a fila, não sem essa fila. -- **Sem autenticação nesta rota ainda** (ver `AGENTS.md` da raiz, decisão de escopo): - `GET /jobs/{id}/events` não confere posse. Um filtro futuro (T-091) cobre isto; até lá - não implemente checagem aqui. +- **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). ## Referências diff --git a/api/pom.xml b/api/pom.xml index 1a506de..ccb77dc 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -68,6 +68,11 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + diff --git a/api/src/main/java/synapse/api/core/config/AppProperties.java b/api/src/main/java/synapse/api/core/config/AppProperties.java index a42b246..88b92b0 100644 --- a/api/src/main/java/synapse/api/core/config/AppProperties.java +++ b/api/src/main/java/synapse/api/core/config/AppProperties.java @@ -28,12 +28,14 @@ * @param rabbitmq broker de mensageria * @param sse stream de acompanhamento do job * @param outbox publicação dos eventos gravados no outbox transacional + * @param keycloak emissor e chaves públicas dos JWTs usados pela API */ @ConfigurationProperties("app") @Validated public record AppProperties(@NotBlank String environment, @NotNull @Valid Service service, @NotNull @Valid Cors cors, @NotNull @Valid Observability observability, @NotNull @Valid Postgres postgres, - @NotNull @Valid Rabbitmq rabbitmq, @NotNull @Valid Sse sse, @NotNull @Valid Outbox outbox) { + @NotNull @Valid Rabbitmq rabbitmq, @NotNull @Valid Sse sse, @NotNull @Valid Outbox outbox, + @NotNull @Valid Keycloak keycloak) { /** * Configura a identidade pública do serviço. @@ -153,4 +155,12 @@ public record Sse(@NotNull Duration heartbeat, @NotNull Duration timeout) { public record Outbox(boolean enabled, @NotNull Duration pollInterval) { } + /** + * @param enabled liga a validação dos tokens do Keycloak + * @param issuerUri emissor OIDC público esperado nos tokens recebidos + * @param jwkSetUri endpoint que a API usa para buscar as chaves públicas + */ + public record Keycloak(boolean enabled, @NotBlank String issuerUri, @NotBlank String jwkSetUri) { + } + } diff --git a/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java new file mode 100644 index 0000000..d03ee14 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java @@ -0,0 +1,7 @@ +package synapse.api.core.security; + +import java.util.UUID; + +/** Identidade local associada à sessão autenticada. */ +public record AcessoDoUsuario(UUID usuarioId, boolean auditor) { +} diff --git a/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java b/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java new file mode 100644 index 0000000..eb68847 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java @@ -0,0 +1,58 @@ +package synapse.api.core.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServletResponse; + +import synapse.api.core.config.AppProperties; + +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "app.keycloak.enabled", havingValue = "true", matchIfMissing = true) +public class SegurancaKeycloakConfig { + + @Bean + SecurityFilterChain cadeiaDeFiltros(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .authorizeHttpRequests(autorizacao -> autorizacao.dispatcherTypeMatchers(DispatcherType.ERROR) + .permitAll() + .requestMatchers("/actuator/health", "/health", "/metrics", "/docs/**", "/redocly", "/openapi/**", + "/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated()) + .exceptionHandling(excecoes -> excecoes + .authenticationEntryPoint((requisicao, resposta, excecao) -> escreverErro(resposta, + HttpServletResponse.SC_UNAUTHORIZED, "nao_autenticado", "Sessão ausente ou expirada.")) + .accessDeniedHandler((requisicao, resposta, excecao) -> escreverErro(resposta, + HttpServletResponse.SC_FORBIDDEN, "sem_permissao", "Você não tem permissão para esta ação."))) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .build(); + } + + @Bean + JwtDecoder decodificadorJwt(AppProperties properties) { + NimbusJwtDecoder decodificador = NimbusJwtDecoder.withJwkSetUri(properties.keycloak().jwkSetUri()).build(); + decodificador.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.keycloak().issuerUri())); + return decodificador; + } + + private static void escreverErro(HttpServletResponse resposta, int status, String codigo, String mensagem) + throws java.io.IOException { + resposta.setStatus(status); + resposta.setContentType("application/json"); + resposta.getWriter().write("{\"codigo\":\"%s\",\"mensagem\":\"%s\"}".formatted(codigo, mensagem)); + } + +} diff --git a/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java b/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java new file mode 100644 index 0000000..211c235 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java @@ -0,0 +1,26 @@ +package synapse.api.core.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Mantém o ambiente local e os testes sem autenticação quando o Keycloak está + * desabilitado. + */ +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "app.keycloak.enabled", havingValue = "false", matchIfMissing = true) +class SegurancaSemKeycloakConfig { + + @Bean + SecurityFilterChain cadeiaDeFiltrosSemKeycloak(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(autorizacao -> autorizacao.anyRequest().permitAll()) + .build(); + } + +} diff --git a/api/src/main/java/synapse/api/core/security/UsuarioAtual.java b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java new file mode 100644 index 0000000..d84c310 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java @@ -0,0 +1,95 @@ +package synapse.api.core.security; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import synapse.api.core.config.AppProperties; + +@Service +public class UsuarioAtual { + + private final JdbcTemplate jdbc; + + private final AppProperties properties; + + public UsuarioAtual(JdbcTemplate jdbc, AppProperties properties) { + this.jdbc = jdbc; + this.properties = properties; + } + + @Transactional + public AcessoDoUsuario obter() { + Authentication autenticacao = SecurityContextHolder.getContext().getAuthentication(); + if (autenticacao == null || !(autenticacao.getPrincipal() instanceof Jwt token)) { + return usuarioDeDesenvolvimento(); + } + + 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)); + } + + 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); + if (usuarios.isEmpty()) { + throw new IllegalStateException("Nenhum usuário ativo disponível."); + } + return new AcessoDoUsuario(usuarios.getFirst(), false); + } + + private UUID encontrarOuCriar(String subject, String login, String nome) { + 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 + """, UUID.class, subject, login); + Timestamp agora = Timestamp.from(Instant.now()); + if (!usuarios.isEmpty()) { + UUID usuarioId = usuarios.getFirst(); + int atualizados = this.jdbc.update(""" + UPDATE usuarios SET keycloak_sub = ?, nome = ?, ultimo_login_em = ? + WHERE id = ? AND ativo = true + """, subject, nome, agora, usuarioId); + if (atualizados == 0) { + throw new AccessDeniedException("Conta inativa."); + } + return usuarioId; + } + 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)); + } + + private static String textoDoToken(Jwt token, String campo, String padrao) { + String valor = token.getClaimAsString(campo); + 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; + } + return papeis.stream().anyMatch("auditor"::equals); + } + +} diff --git a/api/src/main/java/synapse/api/core/web/CorsConfig.java b/api/src/main/java/synapse/api/core/web/CorsConfig.java index 4d4c996..c342bde 100644 --- a/api/src/main/java/synapse/api/core/web/CorsConfig.java +++ b/api/src/main/java/synapse/api/core/web/CorsConfig.java @@ -31,7 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins(origens.toArray(String[]::new)) .allowedMethods("GET", "POST") - .allowedHeaders("Accept", "Content-Type") + .allowedHeaders("Accept", "Authorization", "Content-Type") .allowCredentials(false); } diff --git a/api/src/main/java/synapse/api/job/AcompanharJobController.java b/api/src/main/java/synapse/api/job/AcompanharJobController.java index 04633b6..30c480e 100644 --- a/api/src/main/java/synapse/api/job/AcompanharJobController.java +++ b/api/src/main/java/synapse/api/job/AcompanharJobController.java @@ -11,6 +11,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.UsuarioAtual; @RestController class AcompanharJobController { @@ -19,13 +20,21 @@ class AcompanharJobController { private final CorrelationContext correlacao; - AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao, AutorizadorDeJob autorizador, + UsuarioAtual usuarioAtual) { this.service = service; this.correlacao = correlacao; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @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 new file mode 100644 index 0000000..b9bb621 --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java @@ -0,0 +1,17 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +class AutorizacaoDeJobAdvice { + + @ExceptionHandler(SemPermissaoNoJobException.class) + ResponseEntity semPermissao() { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErroDto("sem_permissao", "Você não tem acesso a este job.")); + } + +} diff --git a/api/src/main/java/synapse/api/job/AutorizadorDeJob.java b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java new file mode 100644 index 0000000..bd4c6ee --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java @@ -0,0 +1,30 @@ +package synapse.api.job; + +import java.util.List; +import java.util.UUID; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import synapse.api.core.security.AcessoDoUsuario; + +@Service +class AutorizadorDeJob { + + private final JdbcTemplate jdbc; + + AutorizadorDeJob(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + void exigirAcesso(UUID jobId, AcessoDoUsuario acesso) { + 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())) { + throw new SemPermissaoNoJobException(); + } + } + +} diff --git a/api/src/main/java/synapse/api/job/BuscarJobAdvice.java b/api/src/main/java/synapse/api/job/BuscarJobAdvice.java new file mode 100644 index 0000000..8c51aea --- /dev/null +++ b/api/src/main/java/synapse/api/job/BuscarJobAdvice.java @@ -0,0 +1,17 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = BuscarJobController.class) +class BuscarJobAdvice { + + @ExceptionHandler(JobNaoEncontradoException.class) + ResponseEntity jobNaoEncontrado() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErroDto("job_nao_encontrado", "Job não encontrado.")); + } + +} diff --git a/api/src/main/java/synapse/api/job/BuscarJobController.java b/api/src/main/java/synapse/api/job/BuscarJobController.java index 87dc90b..bfdaa8b 100644 --- a/api/src/main/java/synapse/api/job/BuscarJobController.java +++ b/api/src/main/java/synapse/api/job/BuscarJobController.java @@ -5,17 +5,26 @@ 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; - BuscarJobController(BuscarJobService service) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + BuscarJobController(BuscarJobService service, AutorizadorDeJob autorizador, UsuarioAtual usuarioAtual) { this.service = service; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @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/CriarJobController.java b/api/src/main/java/synapse/api/job/CriarJobController.java index 57598b3..c9c6e99 100644 --- a/api/src/main/java/synapse/api/job/CriarJobController.java +++ b/api/src/main/java/synapse/api/job/CriarJobController.java @@ -7,18 +7,23 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import synapse.api.core.security.UsuarioAtual; + @RestController class CriarJobController { private final CriarJobService service; - CriarJobController(CriarJobService service) { + private final UsuarioAtual usuarioAtual; + + CriarJobController(CriarJobService service, UsuarioAtual usuarioAtual) { this.service = service; + this.usuarioAtual = usuarioAtual; } @PostMapping(path = "/jobs", consumes = "application/json", produces = "application/json") ResponseEntity criar(@RequestBody String corpo) { - JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo)); + JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), this.usuarioAtual.obter().usuarioId()); return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job); } diff --git a/api/src/main/java/synapse/api/job/CriarJobService.java b/api/src/main/java/synapse/api/job/CriarJobService.java index 26541bc..45cefd3 100644 --- a/api/src/main/java/synapse/api/job/CriarJobService.java +++ b/api/src/main/java/synapse/api/job/CriarJobService.java @@ -3,8 +3,8 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Objects; import java.util.List; +import java.util.Objects; import java.util.UUID; import tools.jackson.databind.json.JsonMapper; @@ -42,7 +42,16 @@ JobCriadoDto criar(CriarJobRequisicao requisicao) { if (usuarios.isEmpty()) { throw CriarJobException.semUsuarioAtivo(); } - UUID usuarioId = usuarios.getFirst(); + return criar(requisicao, usuarios.getFirst()); + } + + @Transactional + JobCriadoDto criar(CriarJobRequisicao requisicao, UUID usuarioId) { + Integer usuariosAtivos = this.jdbc.queryForObject("SELECT count(*) FROM usuarios WHERE id = ? AND ativo = true", + Integer.class, usuarioId); + if (usuariosAtivos == null || usuariosAtivos == 0) { + throw CriarJobException.semUsuarioAtivo(); + } Instant agora = Instant.now().truncatedTo(ChronoUnit.MICROS); Timestamp timestamp = Timestamp.from(agora); RepresentacaoRegraDto representacao = requisicao.representacao(); diff --git a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java index 66d70c8..5a48160 100644 --- a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java +++ b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java @@ -9,6 +9,7 @@ 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 @@ -18,13 +19,21 @@ class ExecutarAcaoController { private final EmissoresSse emissores; - ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores, AutorizadorDeJob autorizador, + UsuarioAtual usuarioAtual) { this.service = service; this.emissores = emissores; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @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 45a7c43..75cb797 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsController.java +++ b/api/src/main/java/synapse/api/job/ListarJobsController.java @@ -4,19 +4,24 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import synapse.api.core.security.UsuarioAtual; + @RestController class ListarJobsController { private final ListarJobsService service; - ListarJobsController(ListarJobsService service) { + private final UsuarioAtual usuarioAtual; + + ListarJobsController(ListarJobsService service, UsuarioAtual usuarioAtual) { this.service = service; + this.usuarioAtual = usuarioAtual; } @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)); + return this.service.listar(new ListarJobsRequisicao(pagina, tamanho), this.usuarioAtual.obter()); } } diff --git a/api/src/main/java/synapse/api/job/ListarJobsService.java b/api/src/main/java/synapse/api/job/ListarJobsService.java index 00b2e95..424af92 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsService.java +++ b/api/src/main/java/synapse/api/job/ListarJobsService.java @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; +import synapse.api.core.security.AcessoDoUsuario; + @Service class ListarJobsService { @@ -25,14 +27,21 @@ 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 * sucesso. */ @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - PaginaJobsDto listar(ListarJobsRequisicao requisicao) { - long total = Objects.requireNonNull(this.jdbc.queryForObject("SELECT count(*) FROM jobs", Long.class)); + 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())); 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 @@ -44,9 +53,11 @@ 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 = ? ORDER BY j.criado_em DESC, j.id DESC LIMIT ? OFFSET ? - """, (linha, numero) -> resumo(linha), requisicao.tamanho(), requisicao.deslocamento()); + """, (linha, numero) -> resumo(linha), acesso.auditor(), 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/SemPermissaoNoJobException.java b/api/src/main/java/synapse/api/job/SemPermissaoNoJobException.java new file mode 100644 index 0000000..f1925b4 --- /dev/null +++ b/api/src/main/java/synapse/api/job/SemPermissaoNoJobException.java @@ -0,0 +1,6 @@ +package synapse.api.job; + +/** A sessão é válida, mas não pode acessar o job solicitado. */ +class SemPermissaoNoJobException extends RuntimeException { + +} diff --git a/api/src/main/resources/application.yaml b/api/src/main/resources/application.yaml index caf6472..a094950 100644 --- a/api/src/main/resources/application.yaml +++ b/api/src/main/resources/application.yaml @@ -85,6 +85,10 @@ app: outbox: enabled: ${OUTBOX_ENABLED:true} poll-interval: ${OUTBOX_POLL_INTERVAL:1s} + keycloak: + enabled: ${KEYCLOAK_ENABLED:false} + issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/synapse} + jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://localhost:8081/realms/synapse/protocol/openid-connect/certs} logging: level: diff --git a/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql b/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql new file mode 100644 index 0000000..f1ac609 --- /dev/null +++ b/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql @@ -0,0 +1,9 @@ +-- liquibase formatted sql + +-- changeset synapse:015-vincula-usuarios-ao-keycloak +ALTER TABLE usuarios ALTER COLUMN senha_hash DROP NOT NULL; +ALTER TABLE usuarios ADD COLUMN keycloak_sub text; +CREATE UNIQUE INDEX uq_usuarios_keycloak_sub ON usuarios (keycloak_sub) WHERE keycloak_sub IS NOT NULL; +-- rollback DROP INDEX uq_usuarios_keycloak_sub; +-- rollback ALTER TABLE usuarios DROP COLUMN keycloak_sub; +-- rollback ALTER TABLE usuarios ALTER COLUMN senha_hash SET NOT NULL; diff --git a/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java b/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java index 66abee0..3b0683e 100644 --- a/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java +++ b/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java @@ -18,7 +18,7 @@ * Um erro na cópia de build deixaria /docs de pé, mas com um contrato ausente ou * incompleto. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.keycloak.enabled=true") @ActiveProfiles("test") class OpenApiDocsTests { diff --git a/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java b/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java index e09a667..76039e6 100644 --- a/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java +++ b/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** Um erro de configuração devolveria os endpoints ao /actuator sem falhar o boot. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.keycloak.enabled=true") @ActiveProfiles("test") class MetricsEndpointTests { diff --git a/api/src/test/java/synapse/api/core/persistence/MigrationTests.java b/api/src/test/java/synapse/api/core/persistence/MigrationTests.java index 3762767..285fe30 100644 --- a/api/src/test/java/synapse/api/core/persistence/MigrationTests.java +++ b/api/src/test/java/synapse/api/core/persistence/MigrationTests.java @@ -37,9 +37,7 @@ class MigrationTests { "regras", "prompts", "respostas_modelo", "codigos_gerados", "resultados_simulacao", "explicacoes", "simulacoes", "trilhas_auditoria", "outbox_events"); - // Uma tabela por changeset, mais o 000 que cria os usuários de banco e não cria - // tabela nenhuma. - private static final int CHANGESETS = TABELAS.size() + 1; + private static final int CHANGESETS = 16; private static PostgreSQLContainer postgres; 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 c6a0e43..e55514f 100644 --- a/api/src/test/java/synapse/api/core/web/CorsConfigTests.java +++ b/api/src/test/java/synapse/api/core/web/CorsConfigTests.java @@ -15,7 +15,8 @@ * sem estes cabeçalhos o navegador descarta a resposta mesmo com a api respondendo 200. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "app.cors.allowed-origins=https://app.exemplo.com,http://localhost:5173") + properties = { "app.keycloak.enabled=true", + "app.cors.allowed-origins=https://app.exemplo.com,http://localhost:5173" }) @ActiveProfiles("test") class CorsConfigTests { @@ -37,7 +38,7 @@ void allowsThePreflightOfAConfiguredOrigin() { .uri("/jobs") .header("Origin", ORIGEM) .header("Access-Control-Request-Method", "POST") - .header("Access-Control-Request-Headers", "content-type") + .header("Access-Control-Request-Headers", "authorization, content-type") .exchange() .expectStatus() .isOk() @@ -47,7 +48,8 @@ void allowsThePreflightOfAConfiguredOrigin() { .value("Access-Control-Allow-Methods", (metodos) -> assertThat(metodos).contains("POST")) .expectHeader() .value("Access-Control-Allow-Headers", - (cabecalhos) -> assertThat(cabecalhos).containsIgnoringCase("Content-Type")); + (cabecalhos) -> assertThat(cabecalhos).containsIgnoringCase("Authorization") + .containsIgnoringCase("Content-Type")); } /** Cada origem da lista vale por si; o separador é vírgula, não um valor só. */ diff --git a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java index 2e67dc7..1093c21 100644 --- a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java @@ -32,7 +32,12 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -91,7 +96,11 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.register(Config.class); contexto.refresh(); service = contexto.getBean(BuscarJobService.class); - mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(BuscarJobController.class)).build(); + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + mvc = MockMvcBuilders + .standaloneSetup(new BuscarJobController(service, contexto.getBean(AutorizadorDeJob.class), usuarioAtual)) + .build(); } @AfterAll @@ -110,7 +119,7 @@ void limpar() { } @TestConfiguration(proxyBeanMethods = false) - @Import({ BuscarJobService.class, BuscarJobController.class }) + @Import({ BuscarJobService.class, AutorizadorDeJob.class }) static class Config { } diff --git a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java index 2b3e167..1e15e1b 100644 --- a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java @@ -18,6 +18,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -39,11 +42,14 @@ class CriarJobControllerTests { private final CriarJobService service = mock(CriarJobService.class); + private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + private MockMvc mvc; @BeforeEach void preparar() { - this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service, this.usuarioAtual)) .setControllerAdvice(new CriarJobAdvice()) .build(); } @@ -51,7 +57,7 @@ void preparar() { @Test void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exception { UUID jobId = UUID.randomUUID(); - when(this.service.criar(any())).thenAnswer(invocacao -> { + when(this.service.criar(any(), any())).thenAnswer(invocacao -> { CriarJobRequisicao requisicao = invocacao.getArgument(0); return new JobCriadoDto(jobId, "gerando_regra", "formulario", requisicao.competencias(), requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), @@ -74,7 +80,7 @@ void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exce "criado_em", "submissao_id", "regra"); assertThat(job.path("regra").propertyNames()).containsExactlyInAnyOrder("id", "versao", "origem", "representacao", "criada_em"); - verify(this.service).criar(any()); + verify(this.service).criar(any(), any()); } @ParameterizedTest @@ -162,7 +168,7 @@ static Stream camposInvalidos() { @Test void semUsuarioAtivoResponde503ComErroDeDominio() throws Exception { - when(this.service.criar(any())).thenThrow(CriarJobException.semUsuarioAtivo()); + when(this.service.criar(any(), any())).thenThrow(CriarJobException.semUsuarioAtivo()); this.mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(FORMULARIO)) .andExpect(status().isServiceUnavailable()) .andExpect(jsonPath("$.codigo").value("usuario_ativo_indisponivel")) @@ -171,7 +177,7 @@ void semUsuarioAtivoResponde503ComErroDeDominio() throws Exception { @Test void naoExpoeErroDoJdbc() throws Exception { - when(this.service.criar(any())).thenThrow(new DataIntegrityViolationException("regras SQL stack trace")); + when(this.service.criar(any(), any())).thenThrow(new DataIntegrityViolationException("regras SQL stack trace")); this.mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(FORMULARIO)) .andExpect(status().isInternalServerError()) .andExpect(content().string("")); diff --git a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java index 4e86268..bc54e6c 100644 --- a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java @@ -38,9 +38,13 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; import synapse.api.core.outbox.Outbox; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -117,8 +121,7 @@ static void encerrar() { @TestConfiguration(proxyBeanMethods = false) @EnableTransactionManagement - @Import({ CriarJobService.class, MaquinaDeEstadosDoJob.class, Outbox.class, CriarJobController.class, - CriarJobAdvice.class }) + @Import({ CriarJobService.class, MaquinaDeEstadosDoJob.class, Outbox.class, CriarJobAdvice.class }) static class Config { } @@ -129,7 +132,9 @@ void httpSemPrincipalPersisteUsuarioAtivoIgnorandoUserIdDoBody() throws Exceptio .replace("\"origem\"", "\"user_id\":\"99999999-9999-4999-8999-999999999999\",\"origem\"") .replace("\"texto_livre\":null", "\"texto_livre\":\"Observação recebida\",\"extra\":{\"preservar\":true}") .replace("0.025", "0.025000000000000000001"); - var mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(CriarJobController.class)) + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + var mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(service, usuarioAtual)) .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 6bf6c5c..20e0320 100644 --- a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java +++ b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java @@ -19,6 +19,8 @@ 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; @@ -41,11 +43,18 @@ 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() { - this.mvc = MockMvcBuilders.standaloneSetup(new ExecutarAcaoController(this.service, this.emissores)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); + this.mvc = MockMvcBuilders + .standaloneSetup( + new ExecutarAcaoController(this.service, this.emissores, this.autorizador, this.usuarioAtual)) .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 533914f..1a5656e 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java @@ -15,6 +15,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -29,36 +32,39 @@ class ListarJobsControllerTests { private final ListarJobsService service = mock(ListarJobsService.class); + private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + private MockMvc mvc; @BeforeEach void preparar() { - this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); + this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service, this.usuarioAtual)) .setControllerAdvice(new ListarJobsAdvice()) .build(); } @Test void semParametrosUsaOsPadroesDoContrato() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 0)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 0)); this.mvc.perform(get("/jobs")).andExpect(status().isOk()); - verify(this.service).listar(new ListarJobsRequisicao(0, 20)); + verify(this.service).listar(any(), any()); } @Test void repassaPaginaETamanhoInformados() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 2, 10, 0)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 2, 10, 0)); this.mvc.perform(get("/jobs").param("pagina", "2").param("tamanho", "10")).andExpect(status().isOk()); - verify(this.service).listar(new ListarJobsRequisicao(2, 10)); + verify(this.service).listar(any(), any()); } @Test void aPaginaTemExatamenteOsQuatroCamposDoContrato() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 3)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 3)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -76,7 +82,7 @@ void umItemCompletoTemOsOitoCamposDoContrato() throws Exception { UUID jobOrigemId = UUID.randomUUID(); JobResumoDto item = new JobResumoDto(id, "liberado", List.of("2025-11"), new BigDecimal("485000.0"), "viavel", Instant.parse("2026-09-16T15:00:00Z"), Instant.parse("2026-09-16T15:05:00Z"), jobOrigemId); - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -98,7 +104,7 @@ void umItemCompletoTemOsOitoCamposDoContrato() throws Exception { void umItemMinimoOmiteOsCamposAusentesSemNull() throws Exception { JobResumoDto item = new JobResumoDto(UUID.randomUUID(), "aguardando_confirmacao_parametros", List.of("2025-11"), new BigDecimal("485000.0"), null, Instant.parse("2026-09-16T15:00:00Z"), null, null); - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -138,7 +144,7 @@ void parametroNaoInteiroResponde400ComOCorpoDeErroPadrao(String nome, String val @Test void falhaDePersistenciaResponde500SemCorpo() throws Exception { - when(this.service.listar(any())).thenThrow(new DataIntegrityViolationException("falha")); + when(this.service.listar(any(), any())).thenThrow(new DataIntegrityViolationException("falha")); this.mvc.perform(get("/jobs")).andExpect(status().isInternalServerError()); } diff --git a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java index 7a65d7e..7cc049f 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java @@ -38,7 +38,12 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; /** @@ -98,7 +103,9 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.register(Config.class); contexto.refresh(); service = contexto.getBean(ListarJobsService.class); - mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(ListarJobsController.class)) + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(service, usuarioAtual)) .setControllerAdvice(contexto.getBean(ListarJobsAdvice.class)) .build(); } @@ -120,7 +127,7 @@ void limpar() { @TestConfiguration(proxyBeanMethods = false) @EnableTransactionManagement - @Import({ ListarJobsService.class, ListarJobsController.class, ListarJobsAdvice.class }) + @Import({ ListarJobsService.class, ListarJobsAdvice.class }) static class Config { } diff --git a/contracts/http/openapi.yaml b/contracts/http/openapi.yaml index 6eb909d..e6918c5 100644 --- a/contracts/http/openapi.yaml +++ b/contracts/http/openapi.yaml @@ -70,7 +70,7 @@ servers: parte deste contrato. security: - - sessaoSynapse: [] + - keycloakOidc: [] tags: - name: jobs @@ -742,20 +742,15 @@ paths: components: securitySchemes: - sessaoSynapse: - type: http - scheme: bearer + keycloakOidc: + type: openIdConnect + openIdConnectUrl: http://localhost:8081/realms/synapse/.well-known/openid-configuration description: | - Sessão emitida pela `api` após login com credencial. A `api` é dona das - credenciais, da sessão e do papel do usuário, sem provedor de identidade - externo. O papel é único por usuário, sem RBAC fino, e as sete - operações deste contrato são do papel `PROFISSIONAL DE RH`; o `AUDITOR` é - perfil investigativo, fora do fluxo operacional (DEC-087). - - A rota que emite a sessão e a forma de renovação não fazem parte desta - versão do contrato e entram com a tarefa de autenticação. O que este arquivo - fixa é que toda operação exige sessão válida e que o acesso a um job confere - posse, e não apenas autenticação. + Autenticação OIDC emitida pelo Keycloak. O cliente envia o access token como + `Authorization: Bearer `; a API valida emissor, assinatura e expiração. + A conta local é associada ao claim `sub`, e o acesso a um job confere posse, + não apenas autenticação. O papel `auditor` no realm autoriza consulta além da + própria conta. parameters: JobId: diff --git a/deploy/.env.example b/deploy/.env.example index ad1eace..9ea8957 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,5 +1,8 @@ # PostgreSQL VITE_API_BASE_URL=http://localhost:8080 +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend POSTGRES_DB=synapse_db POSTGRES_PORT=5432 @@ -38,6 +41,19 @@ DOCKER_GID=999 # API API_PORT=8080 +#Keycloak +KEYCLOAK_ENABLED=true +# Emissor público presente no token retornado ao navegador. +KEYCLOAK_PUBLIC_URL=http://localhost:8081 +KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/synapse +# Endpoint interno usado apenas pela API para buscar as chaves públicas. +KEYCLOAK_JWK_SET_URI=http://keycloak:8080/realms/synapse/protocol/openid-connect/certs + +# Keycloak +KEYCLOAK_PORT=8081 +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=troque-me + # Origens de navegador que a api libera no CORS, separadas por vírgula. # Em staging/produção é a URL do frontend: https://$GATEWAY_APP_DOMAIN. CORS_ALLOWED_ORIGINS=http://localhost:5173,https://app.localhost diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 69534b1..3c99f6f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -9,6 +9,9 @@ services: dockerfile: frontend/Dockerfile args: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-http://localhost:8081} + VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-synapse} + VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-synapse-frontend} image: synapse-frontend:${TAG:-local} restart: "no" command: ['sh', '-c', 'rm -rf /dist/* && cp -r /app/dist/. /dist/'] @@ -193,6 +196,9 @@ services: RABBITMQ_PORT: 5672 RABBITMQ_USER: ${RABBITMQ_DEFAULT_USER:-guest} RABBITMQ_PASSWORD: ${RABBITMQ_DEFAULT_PASS:-guest} + KEYCLOAK_ENABLED: ${KEYCLOAK_ENABLED:-true} + KEYCLOAK_ISSUER_URI: ${KEYCLOAK_ISSUER_URI:-http://localhost:8081/realms/synapse} + KEYCLOAK_JWK_SET_URI: ${KEYCLOAK_JWK_SET_URI:-http://keycloak:8080/realms/synapse/protocol/openid-connect/certs} ports: - '${API_PORT:-8080}:8080' depends_on: @@ -200,6 +206,8 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + keycloak: + condition: service_started healthcheck: test: ['CMD', 'curl', '--fail', '--silent', 'http://localhost:8080/actuator/health'] interval: 30s @@ -229,6 +237,24 @@ services: networks: - synapse-net + keycloak: + image: quay.io/keycloak/keycloak:26.7.4 + container_name: synapse-keycloak + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?defina em deploy/.env} + KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8081} + ports: + - '${KEYCLOAK_PORT:-8081}:8080' + volumes: + - ./keycloak/realm-synapse.json:/opt/keycloak/data/import/realm-synapse.json:ro + - ./keycloak/themes:/opt/keycloak/themes:ro + - keycloak-data:/opt/keycloak/data + networks: + - synapse-net + restart: unless-stopped + alloy: image: grafana/alloy:latest container_name: synapse-alloy @@ -257,6 +283,7 @@ volumes: rabbitmq-data: name: synapse-rabbitmq-data alloy-data: + keycloak-data: networks: synapse-net: diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md new file mode 100644 index 0000000..aff79fd --- /dev/null +++ b/deploy/keycloak/README.md @@ -0,0 +1,104 @@ +# Keycloak local + +A partir da raiz do repositório, prepare `deploy/.env` conforme o +[manual de instalação](../../docs/instalacao.md). Defina +`KEYCLOAK_ADMIN_PASSWORD` e mantenha `KEYCLOAK_ENABLED=true` para validar o fluxo +autenticado. Suba a identidade local junto dos serviços necessários: + +```powershell +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build keycloak postgres rabbitmq api +``` + +O console administrativo fica em `http://localhost:8081/admin/`. As credenciais do +administrador vêm de `KEYCLOAK_ADMIN_USERNAME` e `KEYCLOAK_ADMIN_PASSWORD` em +`deploy/.env`. + +O import cria o realm `synapse` e o cliente público `synapse-frontend`. Crie os +usuários em **synapse → Users**; não crie contas de uso da aplicação no realm +`master`. O usuário autenticado recebe uma conta local na primeira chamada à API. + +O cliente já aceita `http://localhost:5173/*` como retorno de login e logout. Se +o volume `keycloak-data` já existia antes da mudança do realm, ajuste esses valores +no console, pois a importação preserva clientes existentes. + +## Frontend e sessão + +Para executar o frontend pelo Vite, crie `frontend/.env` a partir de +`frontend/.env.example`, caso ainda não exista, e use: + +```dotenv +VITE_API_BASE_URL=/api +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend +``` + +Em `frontend/`, execute `npm ci` e `npm run dev` e acesse +`http://localhost:5173`. O proxy do Vite encaminha `/api` para a API em +`http://localhost:8080`. + +O login usa OIDC com PKCE. Os tokens ficam em memória; ao recarregar a página, o +frontend tenta recuperar a sessão por SSO silencioso. REST e SSE enviam Bearer e +aguardam a renovação do token antes de cada requisição ou abertura do stream. +Uma falha transitória de renovação permite nova tentativa sem descartar o +formulário; uma resposta 401 reinicia o login. + +A API vincula o claim `sub` à conta em `usuarios.keycloak_sub`. Os papéis do realm +são `profissional-rh` e `auditor`; o papel inicial gravado na conta local é +`profissional_rh`. A aplicação uniforme das permissões e da posse dos jobs está +pendente na tarefa do middleware de autorização, conforme o +[ADR-006](../../docs/adrs/ADR-006.md). + +Se executar a API fora do Compose, defina `KEYCLOAK_ENABLED=true` no ambiente da +API. O default de `application.yaml` é `false`; o Compose o habilita por padrão. +Nesse modo local, `KEYCLOAK_JWK_SET_URI` deve apontar para +`http://localhost:8081/realms/synapse/protocol/openid-connect/certs`, pois o nome +`keycloak` só é resolvido dentro da rede Docker. + +## Preparação para staging + +A promoção manual para staging exige configurar o ambiente público. O realm +versionado aceita apenas a origem `http://localhost:5173`; mudar as variáveis do +frontend não altera os retornos permitidos no Keycloak. + +- Configure uma URL pública acessível pelo navegador para o Keycloak. O Caddyfile + versionado ainda não publica uma rota para esse serviço. +- Use essa URL em `KEYCLOAK_PUBLIC_URL` e `VITE_KEYCLOAK_URL`; configure + `KEYCLOAK_ISSUER_URI` como a mesma URL seguida de `/realms/synapse`. +- Mantenha `KEYCLOAK_JWK_SET_URI` acessível à API pela rede Docker. +- Ajuste `Valid Redirect URIs`, `Valid Post Logout Redirect URIs` e `Web Origins` + do cliente para a origem pública do frontend, além de `CORS_ALLOWED_ORIGINS` + da API. +- Reconstrua o frontend após alterar `VITE_*`, pois esses valores são incorporados + ao bundle durante o build. + +Em realms já existentes, aplique os ajustes pelo console: a importação de +inicialização não substitui o realm persistido. O serviço versionado usa +`start-dev`; a configuração do ambiente de staging precisa considerar esse modo +de execução antes da promoção. + +A validação pelo navegador deve cobrir login, recuperação da sessão após recarga, +renovação após a expiração do access token, recebimento de progresso por SSE e +logout. Os testes automatizados não substituem essa validação no endereço público. + +## Tema de login + +O tema visual `synapse` é montado no container pelo Compose. Após alterar arquivos +em `deploy/keycloak/themes/`, recrie somente o Keycloak: + +```powershell +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate keycloak +``` + +O símbolo exibido pelo tema está em +`deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png`. Ele foi +copiado a partir de `frontend/src/assets/imagens/synapse-simbolo.png` para que o +container possa montar o diretório inteiro do tema em modo somente leitura. + +Em um realm criado anteriormente, selecione uma vez o tema no console: +**synapse → Realm settings → Themes → Login theme → synapse → Save**. Realms novos +recebem essa configuração pelo arquivo de importação. + +`KEYCLOAK_PUBLIC_URL` deve ser a mesma URL pública configurada em +`VITE_KEYCLOAK_URL`. A API busca as chaves pela rede Docker, mas valida o emissor +presente no token com essa URL pública. diff --git a/deploy/keycloak/realm-synapse.json b/deploy/keycloak/realm-synapse.json new file mode 100644 index 0000000..4b47670 --- /dev/null +++ b/deploy/keycloak/realm-synapse.json @@ -0,0 +1,29 @@ +{ + "realm": "synapse", + "enabled": true, + "displayName": "Synapse", + "loginTheme": "synapse", + "defaultLocale": "pt-BR", + "supportedLocales": ["pt-BR"], + "roles": { + "realm": [ + { "name": "profissional-rh" }, + { "name": "auditor" } + ] + }, + "clients": [ + { + "clientId": "synapse-frontend", + "name": "Frontend Synapse", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": ["http://localhost:5173/*"], + "attributes": { + "post.logout.redirect.uris": "http://localhost:5173/*" + }, + "webOrigins": ["http://localhost:5173"] + } + ] +} diff --git a/deploy/keycloak/themes/synapse/login/login.ftl b/deploy/keycloak/themes/synapse/login/login.ftl new file mode 100644 index 0000000..95c6844 --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/login.ftl @@ -0,0 +1,74 @@ + + + + + + + Entrar | Synapse + + + + +
+ + + + Synapse + Decision architecture + + + By Titus System +
+ +
+
+

Governança Cognitiva & Execução de Regras

+

Sua voz dita as regras.
A IA garante os resultados.

+

Converta diretrizes executivas estratégicas em lógica de negócio determinística. Simule impactos de risco e orçamentários antes do deploy em produção com auditoria matemática em tempo real.

+ +
    +
  • ◇ SOC2 Type II Certificado
  • +
  • ♧ Criptografia mTLS Ponta a Ponta
  • +
  • ● Isolamento Multi-Tenant Dedicado
  • +
+
+ +
+ +

Acesse sua conta

+

Ambiente seguro de governança executiva Synapse

+ + <#if message?has_content> +

${kcSanitize(message.summary)?no_esc}

+ + +
+ <#if !usernameHidden??> + + + + + + <#if realm.rememberMe && !usernameHidden??> + + + + +
+ +

Precisa de autorização? Solicitar onboarding executivo

+
+
+ + + + diff --git a/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties b/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties new file mode 100644 index 0000000..9d003b8 --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties @@ -0,0 +1,12 @@ +loginAccountTitle=Entre na sua conta +username=Usuário +usernameOrEmail=Usuário ou e-mail +email=E-mail +password=Senha +rememberMe=Lembrar-me +doLogIn=Entrar +doForgotPassword=Esqueceu sua senha? +showPassword=Mostrar senha +hidePassword=Ocultar senha +noAccount=Não possui uma conta? +doRegister=Cadastre-se diff --git a/deploy/keycloak/themes/synapse/login/resources/css/synapse.css b/deploy/keycloak/themes/synapse/login/resources/css/synapse.css new file mode 100644 index 0000000..950d7fb --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/resources/css/synapse.css @@ -0,0 +1,101 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } + +body.synapse-login-page { + min-width: 320px; + height: 100dvh; + margin: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + color: #fff8f3; + background: radial-gradient(127.28% 124.45% at 10% 12%, rgb(232 123 70 / 8%) 0%, rgb(232 123 70 / 0%) 48%), radial-gradient(124.45% 115.97% at 88% 18%, rgb(200 90 40 / 6%) 0%, rgb(200 90 40 / 0%) 45%), radial-gradient(77.78% 127.28% at 45% 90%, rgb(142 60 22 / 5%) 0%, rgb(142 60 22 / 0%) 55%), #432b20; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; +} + +.synapse-topbar { + display: flex; + width: calc(100% - 6rem); + height: clamp(4.8rem, 10vh, 7.4rem); + align-items: flex-start; + justify-content: space-between; + margin: 0 auto; + padding-top: clamp(1rem, 2.5vh, 1.75rem); +} + +.synapse-brand { display: flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; } +.synapse-brand img { width: 2.4rem; height: 2.4rem; object-fit: contain; } +.synapse-brand span { display: grid; gap: .1rem; } +.synapse-brand strong { font-family: Georgia, Cambria, serif; font-size: 1.55rem; letter-spacing: -.035em; } +.synapse-brand small { color: #d15c17; font-size: .59rem; font-weight: 750; letter-spacing: .17em; text-transform: uppercase; } + +.synapse-status { display: inline-flex; align-items: center; gap: .45rem; padding: .52rem .9rem; border: none; border-radius: 999px; color: #391e13; background: #fff; font-size: .72rem; font-weight: 600; } +.synapse-status i { width: .48rem; height: .48rem; border-radius: 50%; background: #1bbb78; } + +.synapse-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(23rem, 27.5rem); width: calc(100% - 6rem); min-height: 0; height: 100%; align-items: center; gap: clamp(3rem, 10vw, 10rem); margin: 0 auto; padding: clamp(.75rem, 1.8vh, 1.2rem) 0; } +.synapse-hero { max-width: 53rem; } + +.synapse-pill { display: inline-flex; align-items: center; gap: .6rem; margin: 0 0 clamp(.9rem, 2vh, 1.6rem); padding: .58rem 1.15rem; border: 1px solid #efd5c7; border-radius: 999px; color: #9a3f0c; background: #fff8f5; box-shadow: 0 .3rem .75rem -.65rem rgb(69 32 17 / 24%); font-size: .95rem; font-weight: 650; line-height: 1; } +.synapse-pill span { display: grid; width: 1rem; height: 1rem; place-items: center; color: #a94710; } +.synapse-pill svg { width: 100%; height: 100%; fill: currentColor; } + +.synapse-hero h1 { max-width: 49rem; margin: 0; font-family: Georgia, Cambria, serif; font-size: clamp(2.4rem, 3.6vw, 4.25rem); font-weight: 400; line-height: 1.1; letter-spacing: -.02em; } +.synapse-hero h1 em { display: block; margin-top: .1rem; color: #f4761c; font-weight: 400; font-style: italic; } +.synapse-description { max-width: 43rem; margin: clamp(.9rem, 2vh, 1.45rem) 0 clamp(1rem, 2.2vh, 1.6rem); color: #e6d3c8; font-size: clamp(.88rem, 1.1vw, 1.05rem); line-height: 1.52; } + +/* Ajustes na caixa de preview baseados no Figma */ +.synapse-preview { background: #e8d6c8; padding: 1.25rem; border-radius: .95rem; } +.synapse-command { display: flex; align-items: center; gap: .85rem; padding: 1rem; border-radius: .5rem; background: #fff; color: #2e1a10; box-shadow: 0 4px 12px rgb(0 0 0 / 5%); margin-bottom: .75rem; } +.synapse-mic { display: grid; width: 2.2rem; height: 2.2rem; place-items: center; border-radius: 50%; color: #fff; background: #c65a0d; font-size: 1.25rem; } +.synapse-mic svg { width: 1.1rem; height: 1.1rem; } +.synapse-command-text { flex: 1; display: grid; gap: .2rem; margin: 0; font-size: .76rem; } +.synapse-command-text b { color: #c65a0d; font-size: .64rem; letter-spacing: .07em; text-transform: uppercase; } +.synapse-command-text p { margin: 0; font-style: italic; color: #5a3c2b; } +.synapse-command small { color: #a98d7b; font: .7rem ui-monospace, monospace; white-space: nowrap; font-weight: 600; } + +.synapse-metrics { display: flex; gap: .75rem; } +.synapse-metrics dl { flex: 1; display: grid; gap: .38rem; margin: 0; padding: 1rem; background: #fff; border-radius: .5rem; box-shadow: 0 4px 12px rgb(0 0 0 / 5%); } +.synapse-metrics dt { color: #a98d7b; font-size: .6rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; } +.synapse-metrics dd { margin: 0; color: #2e1a10; font: 700 .89rem ui-monospace, monospace; } +.synapse-metrics dd small { color: #a98d7b; font: 600 .62rem Inter, sans-serif; } +.synapse-metrics .positive { color: #168455; } +.synapse-metrics .compliance { color: #c65a0d; font-family: Inter, sans-serif; } + +.synapse-trust { display: flex; flex-wrap: wrap; gap: clamp(1rem, 2vw, 2rem); margin: clamp(.9rem, 2vh, 1.55rem) 0 0; padding: 0; color: #d0bba9; list-style: none; font-size: .72rem; } +.synapse-trust li { display: flex; align-items: center; gap: .4rem; } +.synapse-trust li span { color: #d15c17; font-size: 1rem; } + +/* Ajustes no Card de Login baseados no Figma */ +.synapse-card { width: 480px; max-width: 480px; height: 667px; align-self: center; justify-self: end; padding: 3rem 2.25rem 2rem; border: 1px solid rgb(220 193 183 / 50%); border-radius: 24px; color: #2e1a10; background: #fff; box-shadow: 0 30px 70px -15px rgb(30 20 17 / 12%), 0 0 0 1px rgb(220 193 183 / 35%); } +.synapse-card-icon { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border: 1px solid #fce8db; border-radius: .5rem; color: #c65a0d; background: #fff4ed; } +.synapse-card-icon svg { width: 1.2rem; height: 1.2rem; } +.synapse-card h2 { margin: .95rem 0 .35rem; font-family: Georgia, Cambria, serif; font-size: clamp(1.65rem, 2vw, 2rem); font-weight: 400; letter-spacing: -.02em; } +.synapse-card-description { margin: 0 0 2.55rem; color: #8a776c; font-size: .78rem; } +.synapse-message { margin: 0 0 1rem; padding: .7rem .8rem; border-radius: .5rem; color: #8a3f1c; background: #fff0e9; font-size: .8rem; line-height: 1.4; } + +.synapse-form { display: grid; gap: 1.7rem; } +.synapse-field { display: grid; gap: .48rem; color: #2e1a10; font-size: .69rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } +.synapse-input { position: relative; display: block; color: #8d7668; } +.synapse-input > svg { position: absolute; top: 50%; left: .85rem; width: 1rem; height: 1rem; pointer-events: none; transform: translateY(-50%); } +.synapse-input .synapse-password-eye { right: .85rem; left: auto; cursor: pointer; pointer-events: auto; } +.synapse-field input { width: 100%; height: clamp(2.45rem, 5vh, 2.7rem); padding: 0 2.55rem; border: 1px solid #e4cfc2; border-radius: .4rem; outline: none; color: #2e1a10; background: #fff; font: 400 .82rem Inter, sans-serif; letter-spacing: 0; text-transform: none; transition: border-color .16s; } +.synapse-field input::placeholder { color: #baa89e; } +.synapse-field input:focus { border-color: #be510b; } +.synapse-field-heading { display: flex; justify-content: space-between; gap: 1rem; } +.synapse-field-heading a { color: #a9490d; font-size: .67rem; font-weight: 500; text-transform: none; text-decoration: none; } +.synapse-remember { display: flex; align-items: center; gap: .55rem; color: #8a776c; font-size: .75rem; cursor: pointer; } +.synapse-remember input { width: 1rem; height: 1rem; margin: 0; accent-color: #c65a0d; } + +.synapse-submit { display: flex; width: 100%; height: clamp(2.55rem, 5vh, 2.8rem); align-items: center; justify-content: center; gap: .55rem; margin-top: .5rem; border: 0; border-radius: .4rem; cursor: pointer; color: #fff; background: linear-gradient(90deg, #5c3018 0%, #c65a0d 100%); font: 500 .85rem Inter, sans-serif; text-transform: none; transition: filter .16s; } +.synapse-submit:hover { filter: brightness(1.1); } +.synapse-onboarding { margin: 2.75rem 0 0; padding-top: 1.15rem; border-top: 1px solid #f4e9e2; color: #a98d7b; font-size: .72rem; text-align: center; } +.synapse-onboarding a { color: #c65a0d; font-weight: 600; text-decoration: none; } + +.synapse-footer { display: flex; width: calc(100% - 6rem); align-items: center; justify-content: space-between; gap: 1rem; margin: 0 auto; padding: clamp(.65rem, 1.5vh, 1.15rem) 0 clamp(.75rem, 1.8vh, 1.4rem); color: #8d6b56; font-size: .66rem; border-top: none; } +.synapse-footer nav { display: flex; flex-wrap: wrap; gap: 1.2rem; } +.synapse-footer a { color: inherit; text-decoration: none; } +.synapse-footer a:hover { color: #d0bba9; } + +@media (max-width: 1050px) { .synapse-layout { grid-template-columns: minmax(0, 1fr) minmax(23rem, 30rem); } } +@media (min-width: 821px) { .synapse-layout { gap: clamp(3rem, 4vw, 5rem); } .synapse-hero h1 { max-width: none; } .synapse-hero h1 em { white-space: nowrap; } } +@media (max-width: 820px) { body.synapse-login-page { display: block; height: auto; min-height: 100dvh; overflow-y: auto; } .synapse-topbar, .synapse-layout, .synapse-footer { width: min(100% - 2.5rem, 32rem); } .synapse-topbar { height: auto; min-height: 5.8rem; padding-top: 1.25rem; } .synapse-layout { grid-template-columns: 1fr; min-height: auto; height: auto; padding: 1rem 0 2rem; } .synapse-hero { max-width: none; } .synapse-card { width: 100%; max-width: none; height: auto; min-height: 0; } .synapse-footer { align-items: flex-start; flex-direction: column; } .synapse-metrics { flex-direction: column; } } diff --git a/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png b/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png new file mode 100644 index 0000000..1c8fb87 Binary files /dev/null and b/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png differ diff --git a/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico b/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico new file mode 100644 index 0000000..2cc0862 Binary files /dev/null and b/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico differ diff --git a/deploy/keycloak/themes/synapse/login/theme.properties b/deploy/keycloak/themes/synapse/login/theme.properties new file mode 100644 index 0000000..31b3a9a --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/theme.properties @@ -0,0 +1,3 @@ +parent=keycloak.v2 +import=common/keycloak +styles=css/synapse.css diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 343ce88..72e8606 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -333,9 +333,9 @@ Como o pipeline trata a entrada em geração de código como a fronteira entre a **Aspectos técnicos a definir pela equipe de frontend:** - Gerenciamento de estado (ex.: Pinia) para o estado do job corrente (parâmetros, progresso, resultado) e para sessão/autenticação. -- Roteamento (Vue Router) entre as telas acima, com guarda de rota autenticada: o login envia credencial à API, que devolve a sessão; o guarda barra as rotas sem sessão válida e as que o papel do usuário não autoriza. -- Cliente SSE com reconexão automática (o job pode durar minutos; a conexão pode cair) e deduplicação de eventos. -- Cliente REST para as ações síncronas (submissão, consulta de histórico, ações de finalização). +- Roteamento (Vue Router) entre as telas acima, com guarda de rota autenticada: o Keycloak autentica via OIDC com PKCE, o frontend mantém os tokens apenas em memória e recupera a sessão por SSO silencioso ao recarregar. A guarda exige sessão autenticada; a aplicação das permissões por papel segue a tarefa do middleware de autorização. +- Cliente SSE via `fetch`, com `Authorization: Bearer`, reconexão automática e deduplicação de resultados. Cada abertura do stream aguarda a renovação do token pelo adaptador Keycloak. +- Cliente REST para as ações síncronas (submissão, consulta de histórico, ações de finalização), usando o mesmo transporte autenticado. Antes de enviar a requisição, o cliente chama `updateToken(30)`; uma falha transitória de renovação preserva a sessão e permite tentar novamente. Respostas 401 reiniciam o login. - Validação client-side complementar (não substitui a validação da API) para reduzir round-trips óbvios. - Acessibilidade e clareza de mensagens - requisito não funcional do parceiro é que o sistema seja usável por quem "não tem domínio de tecnologia". @@ -362,7 +362,8 @@ Como o pipeline trata a entrada em geração de código como a fronteira entre a - **Gestão de SSE** - mapeamento `job_id → emissores conectados`, para repassar cada evento de progresso consumido do RabbitMQ ao(s) cliente(s) Frontend inscritos naquele job. Também é o que sustenta a retenção do trabalho na saída abrupta (US07, cenário 2): o job vive na API, não na aba do navegador, então fechar a página não descarta o processamento - o Frontend apenas alerta antes de sair e reencontra o job no histórico. - **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. -- **Segurança** - Spring Security com autenticação local: a API é dona das credenciais e da sessão. A senha é guardada apenas como hash de KDF, nunca em claro. A autorização é por papel único, sem RBAC fino — a matriz de papel × ação está na DEC-087, que também fixa os dois papéis existentes: profissional de RH e auditor. Não há provedor de identidade externo nem SSO: o MVP não precisa de federação, e a granularidade de permissão que o produto exige cabe em dois papéis. +- **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. **Pontos de atenção para o desenvolvimento:** diff --git a/docs/adrs/ADR-003.md b/docs/adrs/ADR-003.md index 8c48b5b..7b54b99 100644 --- a/docs/adrs/ADR-003.md +++ b/docs/adrs/ADR-003.md @@ -1,5 +1,7 @@ # ADR-003 - Detalhamento dos componentes de desenvolvimento +> A decisão de autenticação própria da API foi substituída pelo [ADR-006](ADR-006.md), que adota o Keycloak. + ## Como ler este documento Cada componente (seções 1.1 a 1.4) segue o mesmo roteiro: @@ -53,8 +55,8 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç | Módulo | Função | Detalhe | | --- | --- | --- | | Store de estado | Guarda o estado do job corrente (parâmetros, progresso, resultado) e a sessão/autenticação | Ex.: Pinia | -| Router + guarda de rota | Navegação entre as telas, com guarda de rota autenticada | Vue Router; a sessão vem da API após login com credencial, e o guarda barra rota sem sessão válida ou fora do papel do usuário | -| Cliente SSE | Recebe os eventos de progresso do job | Reconexão automática (o job dura minutos, a conexão pode cair) e deduplicação de eventos | +| Router + guarda de rota | Navegação entre as telas, com guarda de rota autenticada | Vue Router; login OIDC com PKCE no Keycloak, tokens em memória e recuperação por SSO silencioso. A guarda exige sessão autenticada; a autorização por papel pertence à tarefa do middleware (ADR-006 e DEC-087) | +| Cliente SSE | Recebe os eventos de progresso do job | `fetch` com Bearer, renovação do token antes de cada conexão, reconexão automática e deduplicação de resultados | | Cliente REST | Ações síncronas | Submissão, consulta de histórico, ações de finalização | | Validação client-side | Reduz round-trips óbvios | **Complementar** - não substitui a validação da API | @@ -64,7 +66,7 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç | --- | --- | --- | | API | REST | Ações síncronas: submissão da regra, confirmação de parâmetros, histórico, finalização | | API | SSE | Acompanhamento em tempo real do job (seção 6.4) | -| API | REST | Login com credencial e obtenção da sessão usada nas demais chamadas | +| Keycloak | OIDC com PKCE | Login, logout, SSO e renovação dos tokens usados nas chamadas REST e SSE à API | ### Decisões-chave @@ -75,7 +77,7 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç ### A definir pela equipe - Biblioteca de gerenciamento de estado (ex.: Pinia) e modelagem do store do job. -- Forma de transporte e renovação da sessão devolvida pela API. +- Aplicação uniforme das permissões por papel e da posse dos jobs no middleware de autorização, conforme a DEC-087. O transporte usa Bearer e a renovação dos tokens é feita pelo adaptador Keycloak (ADR-006). - Estratégia de reconexão e deduplicação de eventos no cliente SSE. - Escopo da validação client-side complementar. @@ -114,13 +116,14 @@ Porta de entrada única do sistema; dona do ciclo de vida e do estado do `job`; | **Gestão de SSE** | Mapeamento `job_id → emissores conectados` | Repassa cada evento de progresso consumido do RabbitMQ ao(s) cliente(s) Frontend inscritos naquele job. Também sustenta a retenção do trabalho na saída abrupta (US07, cenário 2): o job vive na API, não na aba do navegador. | | **Persistência (Spring Data JPA)** | Repositórios da tabela `job` e das tabelas relacionadas (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 em Formatted SQL (seção 5), inclusive as tabelas que Worker e codegen escrevem. Eles inserem; não criam nem alteram estrutura. Um `.sql` por tabela sob um changelog raiz em YAML que só encadeia. | -| **Segurança** | Autenticação local e autorização por papel | Spring Security; a API é dona das credenciais e da sessão, e guarda a senha apenas como hash de KDF. Papel único por usuário, sem RBAC fino: profissional de RH e auditor, com a matriz de papel × ação na DEC-087. Sem provedor de identidade externo nem SSO. | +| **Segurança** | Autenticação OIDC com Keycloak; autorização por papel e posse na API | Conforme o [ADR-006](ADR-006.md), o Keycloak mantém credenciais e sessão. Spring Security valida o JWT, e a API vincula o `sub` ao usuário local. A matriz da DEC-087 permanece vigente; sua aplicação uniforme é trabalho pendente do middleware de autorização. | ### Integrações | Contraparte | Canal | Uso | | --- | --- | --- | -| Frontend | REST + SSE | Ações síncronas e stream de progresso (seção 6.4) | +| Frontend | REST + SSE | Ações síncronas e stream de progresso com access token Bearer (seção 6.4) | +| Keycloak | OIDC / JWKS | Emissor dos JWTs; a API busca as chaves públicas e valida assinatura, emissor e validade | | codegen | RabbitMQ | Produz `regra-submetida`, `parametros-confirmados`; consome `no-concluido`, `etapa-alterada` | | Worker | RabbitMQ (exchange fanout) | Consome `simulacao-concluida` | | PostgreSQL | JDBC (usuário próprio, acesso total) | Dona do estado, do histórico e das migrations; grava áudio e transcrição; lê todos os artefatos (seção 6.2) | diff --git a/docs/adrs/ADR-006.md b/docs/adrs/ADR-006.md new file mode 100644 index 0000000..7f2d9e9 --- /dev/null +++ b/docs/adrs/ADR-006.md @@ -0,0 +1,44 @@ +# ADR-006 — Keycloak como provedor de identidade + +- Status: aceito +- Data: 2026-09-20 +- Decisor: responsável pelo projeto + +## Contexto + +O frontend precisa de login, guarda de rota e logout, e a API precisa validar a +identidade de quem acessa jobs. Manter senha e sessão próprias duplicaria funções +de um provedor de identidade e ampliaria o escopo de segurança da API. + +Esta decisão substitui a autenticação local anteriormente descrita no +[ADR-003](ADR-003.md) e na seção 3.2 da [arquitetura](../ARCHITECTURE.md). + +## Decisão + +O Keycloak é o provedor OIDC do Synapse. O frontend é um cliente público com PKCE, +mantém o token em memória e recupera a sessão por SSO silencioso. A API atua como +resource server, valida JWTs emitidos pelo realm `synapse` e associa o claim `sub` +ao usuário local por `usuarios.keycloak_sub`. O Keycloak sobe em Docker no +desenvolvimento. + +REST e SSE enviam o access token no cabeçalho `Authorization: Bearer`. O cliente +aguarda `updateToken(30)` antes de cada requisição e de cada abertura ou reconexão +do stream. Uma falha transitória de renovação não descarta a sessão nem o +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. + +## 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. +- O Keycloak passa a ser uma dependência operacional da API nos ambientes com + autenticação habilitada. diff --git a/docs/database/modelo-dados.dbml b/docs/database/modelo-dados.dbml index 9777137..44e1339 100644 --- a/docs/database/modelo-dados.dbml +++ b/docs/database/modelo-dados.dbml @@ -37,11 +37,12 @@ Table usuarios { id uuid [pk, default: `uuidv7()`] - login text [not null, unique, note: 'credencial de acesso'] - senha_hash text [not null, note: 'hash derivado da senha (bcrypt/argon2); nunca a senha em claro'] + login text [not null, unique, note: 'identificador exibido da conta no Keycloak'] + senha_hash text [note: 'legado de autenticação local; nulo para contas do Keycloak'] + keycloak_sub text [unique, note: 'claim sub do Keycloak; identifica a conta de forma estável'] nome text [not null, note: 'nome de exibicao'] papel text [not null, note: 'profissional_rh | auditor (DEC-087)'] - ativo bool [not null, default: true, note: 'desativacao logica; false impede o login'] + ativo bool [not null, default: true, note: 'desativacao logica; false impede a resolucao da conta local pela API, sem encerrar a sessao no Keycloak'] criado_em timestamptz [not null] atualizado_em timestamptz [note: 'null ate a primeira alteracao'] ultimo_login_em timestamptz [note: 'null ate o primeiro acesso'] diff --git a/docs/database/modelo-dados.md b/docs/database/modelo-dados.md index fe99127..5a5234e 100644 --- a/docs/database/modelo-dados.md +++ b/docs/database/modelo-dados.md @@ -16,7 +16,11 @@ São catorze, num schema único. Cada uma aparece abaixo com o que guarda e, qua ### `usuarios` -Quem entra no sistema. A autenticação é local, feita pela própria API, sem provedor de identidade externo. Usuário desativado mantém a linha, porque jobs antigos a referenciam. +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`. + +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. ### `submissoes` diff --git a/docs/instalacao.md b/docs/instalacao.md index b7dae34..1650bc3 100644 --- a/docs/instalacao.md +++ b/docs/instalacao.md @@ -2,19 +2,20 @@ Este documento orienta a inicialização e operação do ambiente local do projeto **Synapse** via Docker Compose. -O ambiente de infraestrutura fornece a base de persistência de dados e mensageria assíncrona para os quatro componentes de desenvolvimento (`frontend`, `api`, `codegen` e `worker`), conforme definido em [ADR-001](../docs/adrs/ADR-001.md) e [ARCHITECTURE.md](../docs/ARCHITECTURE.md). +O ambiente de infraestrutura fornece autenticação pelo Keycloak, persistência de dados e mensageria assíncrona para os quatro componentes de desenvolvimento (`frontend`, `api`, `codegen` e `worker`), conforme definido em [ADR-001](../docs/adrs/ADR-001.md) e [ARCHITECTURE.md](../docs/ARCHITECTURE.md). --- ## 1. Serviços Contemplados -A infraestrutura local é composta pelos serviços de infraestrutura e pelo codegen: +A infraestrutura local inclui os serviços abaixo: 1. **PostgreSQL 18**: Armazenamento único do sistema (armazena estado dos jobs, artefatos gerados, checkpoints e trilhas de auditoria). Configurado com volume persistente e criação automática do banco `synapse_db`. 2. **RabbitMQ 3.13 (com Management UI)**: Broker de mensageria assíncrona para troca de eventos e comandos entre a API e os workers, com painel administrativo web exposto. 3. **API**: aplicação Spring Boot que expõe health check e métricas de infraestrutura. 4. **codegen**: Processo FastAPI que expõe somente os endpoints operacionais de saúde e métricas. 5. **worker**: Processo que consome a fila de execução e sobe os containers efêmeros do sandbox. Diferente dos demais, ele precisa alcançar o **daemon do Docker do host** — ver a seção 2.1. +6. **Keycloak 26.7.4**: Provedor OIDC de identidade. Importa o realm `synapse` e o cliente público `synapse-frontend`, com login local em `http://localhost:8081`. --- ## 2. Pré-requisitos @@ -64,20 +65,26 @@ O compose não sobe sem essa variável: ela é declarada como obrigatória justa ## 3. Inicialização Rápida (Comando Único) -Todo o ambiente de infraestrutura sobe com um único comando, sem necessidade de configuração manual adicional: +Antes do primeiro uso, crie `deploy/.env` a partir de `deploy/.env.example`, caso o arquivo ainda não exista. Preencha as credenciais dos serviços, `DOCKER_GID` e `KEYCLOAK_ADMIN_PASSWORD`. O Compose exige essas variáveis mesmo em comandos que selecionam apenas um serviço. + +```bash +cp deploy/.env.example deploy/.env +``` + +Use `--env-file deploy/.env` ao executar os comandos da raiz do repositório. A configuração de usuários da aplicação e do frontend está no [guia do Keycloak local](../deploy/keycloak/README.md). ### Antes do primeiro `up`: a imagem do sandbox O `worker` executa cada job num container criado a partir da imagem `synapse-sandbox`, e essa imagem não sobe com o `up`: ela só é construída. Construa-a uma vez, e de novo quando `worker/sandbox/` mudar. Sem ela, o `worker` sobe normalmente, mas todo comando de execução falha e acaba na fila de DLQ. ```bash -docker compose -f deploy/docker-compose.yml --profile build build sandbox +docker compose --env-file deploy/.env -f deploy/docker-compose.yml --profile build build sandbox ``` ### Opção A: A partir da raiz do repositório ```bash -docker compose -f deploy/docker-compose.yml up -d +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` ### Opção B: A partir do diretório `deploy/` @@ -90,7 +97,7 @@ docker compose up -d > **Dica:** Para que o comando aguarde até que todos os serviços passem nos seus respectivos *healthchecks* antes de liberar o terminal, utilize a flag `--wait`: > > ```bash -> docker compose -f deploy/docker-compose.yml up -d --wait +> docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --wait > ``` --- @@ -107,19 +114,20 @@ Os nomes de host, portas e credenciais abaixo são padronizados para desenvolvim | **codegen** | `synapse-infra-codegen-1` | `codegen` | `8001` | `8000` | — | — | — | [http://localhost:8001/health](http://localhost:8001/health) | | **worker** | `synapse-infra-worker-1` | `worker` | `8002` | `8000` | — | — | — | [http://localhost:8002/health](http://localhost:8002/health) | | **API** | `synapse-infra-api-1` | `api` | `8080` | `8080` | — | — | — | [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health) | +| **Keycloak** | `synapse-keycloak` | `keycloak` | `8081` | `8080` | `KEYCLOAK_ADMIN_USERNAME` | `KEYCLOAK_ADMIN_PASSWORD` | realm `synapse` | [http://localhost:8081/admin/](http://localhost:8081/admin/) | -As variáveis de ambiente padrão estão declaradas e versionadas em `deploy/.env.example` (copie para `deploy/.env` se quiser sobrescrever os defaults do compose). +As variáveis de ambiente estão documentadas em `deploy/.env.example`; `deploy/.env` deve fornecer as variáveis obrigatórias descritas na seção 3. --- ## 5. Verificação de Saúde (Healthcheck) -Cada serviço possui um *healthcheck* configurado. O ambiente só é considerado pronto quando todos os containers estiverem com status **`healthy`**: +Confira o status **`healthy`** dos serviços que possuem *healthcheck*. O Keycloak não tem *healthcheck* configurado no Compose; valide sua inicialização pelo endpoint OIDC abaixo. O frontend do Compose é uma tarefa de build e cópia, que termina após concluir essa operação: Execute: ```bash -docker compose -f deploy/docker-compose.yml ps +docker compose --env-file deploy/.env -f deploy/docker-compose.yml ps ``` Saída esperada: @@ -170,7 +178,7 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. O `worker` verifica o acesso ao daemon e declara a fila durante a subida. Os dois ficam registrados no log: ```bash - docker compose -f deploy/docker-compose.yml logs worker | grep -E "daemon|fila" + docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs worker | grep -E "daemon|fila" ``` ```json @@ -180,6 +188,14 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. Se essas linhas não aparecem, o processo não subiu - confira a seção 7. +6. **Keycloak**: + + ```bash + curl --fail http://localhost:8081/realms/synapse/.well-known/openid-configuration + ``` + + O endpoint deve devolver o documento OIDC com `issuer` igual a `http://localhost:8081/realms/synapse`. Para testar login, renovação e logout pelo frontend, siga o [guia do Keycloak](../deploy/keycloak/README.md). + --- ## 6. Ciclo de Vida e Persistência de Dados @@ -189,23 +205,23 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. Para interromper a infraestrutura sem perder tabelas, registros ou filas: ```bash -docker compose -f deploy/docker-compose.yml down +docker compose --env-file deploy/.env -f deploy/docker-compose.yml down ``` -Os dados permanecem seguros nos volumes nomeados Docker (`synapse-postgres-data` e `synapse-rabbitmq-data`). Ao rodar `docker compose up -d` novamente, todo o estado anterior é restaurado. +Os volumes nomeados preservam os dados de PostgreSQL (`synapse-postgres-data`), RabbitMQ (`synapse-rabbitmq-data`) e Keycloak (`keycloak-data` no Compose). Ao subir os serviços novamente, esses volumes são reutilizados, incluindo os usuários e o realm do Keycloak. ### Reiniciar os serviços ```bash -docker compose -f deploy/docker-compose.yml restart +docker compose --env-file deploy/.env -f deploy/docker-compose.yml restart ``` ### Resetar o ambiente (Destruição total de dados) -Caso precise recriar os bancos e filas totalmente do zero (apagando todos os dados persistidos): +Caso precise recriar os bancos, filas e a configuração do Keycloak totalmente do zero (apagando também seus usuários, realm e sessões persistidas): ```bash -docker compose -f deploy/docker-compose.yml down -v +docker compose --env-file deploy/.env -f deploy/docker-compose.yml down -v ``` --- @@ -225,7 +241,7 @@ Se o compose recusa a subida com `defina em deploy/.env; ver docs/instalacao.md` Se o container do `worker` sobe e morre em seguida, o log traz a causa: ```bash -docker compose -f deploy/docker-compose.yml logs worker | tail -20 +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs worker | tail -20 ``` ```text @@ -244,11 +260,11 @@ Para acompanhar a saída de logs em tempo real: ```bash # Todos os serviços -docker compose -f deploy/docker-compose.yml logs -f +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f # Somente Postgres -docker compose -f deploy/docker-compose.yml logs -f postgres +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f postgres # Somente RabbitMQ -docker compose -f deploy/docker-compose.yml logs -f rabbitmq +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f rabbitmq ``` diff --git a/frontend/.env.example b/frontend/.env.example index 5e1cd83..cc6e56e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,6 @@ # Never put a secret here: keys and tokens live in the backend. VITE_API_BASE_URL=/api +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 74784ca..9dd66f5 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,5 +13,11 @@ COPY frontend ./ COPY contracts /contracts ARG VITE_API_BASE_URL=/api +ARG VITE_KEYCLOAK_URL=http://localhost:8081 +ARG VITE_KEYCLOAK_REALM=synapse +ARG VITE_KEYCLOAK_CLIENT_ID=synapse-frontend ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL +ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM +ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID RUN npm run build diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 0ef670e..94c509e 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -8,6 +8,12 @@ interface ImportMetaEnv { /** API base. Absolute URL or '/...' path. Default: '/api'. */ readonly VITE_API_BASE_URL?: string + /** Base pública do servidor Keycloak. */ + readonly VITE_KEYCLOAK_URL?: string + /** Realm que emite os tokens da aplicação. */ + readonly VITE_KEYCLOAK_REALM?: string + /** Cliente público OIDC do frontend. */ + readonly VITE_KEYCLOAK_CLIENT_ID?: string } interface ImportMeta { diff --git a/frontend/index.html b/frontend/index.html index bcf5d99..75626a2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Synapse diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 21301fe..521e711 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "keycloak-js": "^26.2.4", "pinia": "^4.0.2", "vue": "^3.5.40", "vue-router": "^5.2.0" @@ -3617,6 +3618,12 @@ "dev": true, "license": "MIT" }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0" + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 19df488..c1430a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "keycloak-js": "^26.2.4", "pinia": "^4.0.2", "vue": "^3.5.40", "vue-router": "^5.2.0" diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..192697e --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/TheProcessHeader.vue b/frontend/src/components/TheProcessHeader.vue index 1ede4b7..131ec08 100644 --- a/frontend/src/components/TheProcessHeader.vue +++ b/frontend/src/components/TheProcessHeader.vue @@ -1,3 +1,20 @@ + +