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
38 changes: 38 additions & 0 deletions .github/workflows/cd-keycloak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: cd-keycloak

on:
push:
branches:
- staging
paths:
- 'deploy/keycloak/**'
- 'deploy/docker-compose.yml'
- '.github/workflows/cd-keycloak.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: cd-staging-deploy
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
steps:
- name: Implantar Keycloak em staging
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.STAGING_SSH_HOST }}
username: ${{ secrets.STAGING_SSH_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
set -e
cd ${{ secrets.STAGING_DEPLOY_PATH }}
git fetch origin staging
git reset --hard origin/staging

docker compose -f deploy/docker-compose.yml --env-file deploy/.env pull keycloak
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --no-deps --force-recreate --wait --wait-timeout 180 keycloak
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ deploy/.env
__pycache__/
.digital-ocean
.claude
tasks/
venv/
.venv/
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
8 changes: 6 additions & 2 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 Expand Up @@ -147,14 +152,13 @@
<scope>test</scope>
</dependency>

<!-- Valida o payload publicado contra o schema real em contracts/events/,
<!-- Valida especificações recebidas e payloads de eventos contra os schemas canônicos,
Jackson 2 (com.fasterxml), sem conflito com o Jackson 3 (tools.jackson)
usado pelo restante da aplicação. -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

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();
}
}

}
Loading
Loading