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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions api/.agents/skills/sse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- Cliente AMQP + autoconfiguração da ConnectionFactory, do health indicator
e das métricas. -->
<dependency>
Expand Down
12 changes: 11 additions & 1 deletion api/src/main/java/synapse/api/core/config/AppProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
}

}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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();
}

}
95 changes: 95 additions & 0 deletions api/src/main/java/synapse/api/core/security/UsuarioAtual.java
Original file line number Diff line number Diff line change
@@ -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<UUID> 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<UUID> 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<String, Object> acessoDoRealm = token.getClaimAsMap("realm_access");
if (acessoDoRealm == null || !(acessoDoRealm.get("roles") instanceof List<?> papeis)) {
return false;
}
return papeis.stream().anyMatch("auditor"::equals);
}

}
2 changes: 1 addition & 1 deletion api/src/main/java/synapse/api/core/web/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
11 changes: 10 additions & 1 deletion api/src/main/java/synapse/api/job/AcompanharJobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<SseEmitter> 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);
Expand Down
17 changes: 17 additions & 0 deletions api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java
Original file line number Diff line number Diff line change
@@ -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<ErroDto> semPermissao() {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErroDto("sem_permissao", "Você não tem acesso a este job."));
}

}
30 changes: 30 additions & 0 deletions api/src/main/java/synapse/api/job/AutorizadorDeJob.java
Original file line number Diff line number Diff line change
@@ -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<UUID> 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();
}
}

}
17 changes: 17 additions & 0 deletions api/src/main/java/synapse/api/job/BuscarJobAdvice.java
Original file line number Diff line number Diff line change
@@ -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<ErroDto> jobNaoEncontrado() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErroDto("job_nao_encontrado", "Job não encontrado."));
}

}
11 changes: 10 additions & 1 deletion api/src/main/java/synapse/api/job/BuscarJobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading