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
11 changes: 7 additions & 4 deletions api/.agents/skills/sse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ emissor é removido).
- **Autenticação do stream.** Com Keycloak habilitado, `GET /jobs/{id}/events`
exige JWT Bearer, como as demais rotas de jobs. O frontend usa `fetch` para enviar
o cabeçalho e renova o token antes de abrir ou reconectar o stream.
- **Autorização em tarefa própria.** O controller já chama `AutorizadorDeJob`;
preserve essa checagem ao alterar o transporte. A cobertura uniforme de posse,
papéis e respostas de acesso negado pertence à tarefa do middleware de
autorização, conforme o [ADR-006](../../../../docs/adrs/ADR-006.md).
- **Autorização antes do stream.** O método do controller declara
`@AutorizarJob(OperacaoJob.ACOMPANHAR)`. O interceptor aplica `AutorizadorDeJob`
antes de executar o controller ou criar `SseEmitter`: somente profissional de
RH dono do job pode abrir o stream. Cada abertura ou reconexão repete a checagem;
auditor, papel inválido e não dono recebem 403 JSON. Preserve essa ordem ao
alterar o transporte, conforme a matriz HTTP da
[DEC-087](../../../../docs/decisoes/dec-087.md).

## Referências

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import java.util.UUID;

/** Identidade local associada à sessão autenticada. */
public record AcessoDoUsuario(UUID usuarioId, boolean auditor) {
public record AcessoDoUsuario(UUID usuarioId, PapelDoUsuario papel) {
}
27 changes: 27 additions & 0 deletions api/src/main/java/synapse/api/core/security/PapelDoUsuario.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package synapse.api.core.security;

import org.springframework.security.access.AccessDeniedException;

public enum PapelDoUsuario {

PROFISSIONAL_RH("profissional_rh"), AUDITOR("auditor");

private final String coluna;

PapelDoUsuario(String coluna) {
this.coluna = coluna;
}

public String paraColuna() {
return this.coluna;
}

public static PapelDoUsuario deColuna(String papel) {
return switch (papel) {
case "profissional_rh" -> PROFISSIONAL_RH;
case "auditor" -> AUDITOR;
default -> throw new AccessDeniedException("Papel não reconhecido.");
};
}

}
36 changes: 22 additions & 14 deletions api/src/main/java/synapse/api/core/security/UsuarioAtual.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,33 @@ public AcessoDoUsuario obter() {
return usuarioDeDesenvolvimento();
}

PapelDoUsuario papel = papelDoToken(token);
String subject = Objects.requireNonNull(token.getSubject());
String login = textoDoToken(token, "preferred_username", subject);
String nome = textoDoToken(token, "name", login);
UUID usuarioId = encontrarOuCriar(subject, login, nome);
return new AcessoDoUsuario(usuarioId, temPapelDeAuditor(token));
UUID usuarioId = encontrarOuCriar(subject, login, nome, papel);
return new AcessoDoUsuario(usuarioId, papel);
}

private AcessoDoUsuario usuarioDeDesenvolvimento() {
if (this.properties.keycloak().enabled()) {
throw new IllegalStateException("Sessão autenticada não encontrada.");
}
List<UUID> usuarios = this.jdbc
.queryForList("SELECT id FROM usuarios WHERE ativo = true ORDER BY criado_em, id LIMIT 1", UUID.class);
List<AcessoDoUsuario> usuarios = this.jdbc.query(
"SELECT id, papel FROM usuarios WHERE ativo = true ORDER BY criado_em, id LIMIT 1",
(linha, numero) -> new AcessoDoUsuario(Objects.requireNonNull(linha.getObject("id", UUID.class)),
PapelDoUsuario.deColuna(Objects.requireNonNull(linha.getString("papel")))));
if (usuarios.isEmpty()) {
throw new IllegalStateException("Nenhum usuário ativo disponível.");
}
return new AcessoDoUsuario(usuarios.getFirst(), false);
return usuarios.getFirst();
}

private UUID encontrarOuCriar(String subject, String login, String nome) {
private UUID encontrarOuCriar(String subject, String login, String nome, PapelDoUsuario papel) {
List<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
ORDER BY CASE WHEN keycloak_sub IS NULL THEN 1 ELSE 0 END, criado_em, id LIMIT 1
""", UUID.class, subject, login);
Timestamp agora = Timestamp.from(Instant.now());
if (!usuarios.isEmpty()) {
Expand All @@ -75,21 +78,26 @@ private UUID encontrarOuCriar(String subject, String login, String nome) {
}
return Objects.requireNonNull(this.jdbc.queryForObject("""
INSERT INTO usuarios (login, senha_hash, nome, papel, ativo, criado_em, ultimo_login_em, keycloak_sub)
VALUES (?, NULL, ?, 'profissional_rh', true, ?, ?, ?) RETURNING id
""", UUID.class, login, nome, agora, agora, subject));
VALUES (?, NULL, ?, ?, true, ?, ?, ?) RETURNING id
""", UUID.class, login, nome, papel.paraColuna(), agora, agora, subject));
}

private static String textoDoToken(Jwt token, String campo, String padrao) {
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;
private static PapelDoUsuario papelDoToken(Jwt token) {
Object acessoDoRealm = token.getClaim("realm_access");
if (!(acessoDoRealm instanceof Map<?, ?> realm) || !(realm.get("roles") instanceof List<?> papeis)) {
throw new AccessDeniedException("Papel não reconhecido.");
}
return papeis.stream().anyMatch("auditor"::equals);
boolean rh = papeis.contains("profissional-rh");
boolean auditor = papeis.contains("auditor");
if (rh == auditor) {
throw new AccessDeniedException("Exatamente um papel de negócio é obrigatório.");
}
return rh ? PapelDoUsuario.PROFISSIONAL_RH : PapelDoUsuario.AUDITOR;
}

}
12 changes: 2 additions & 10 deletions api/src/main/java/synapse/api/job/AcompanharJobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

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

@RestController
class AcompanharJobController {
Expand All @@ -20,21 +19,14 @@ class AcompanharJobController {

private final CorrelationContext correlacao;

private final AutorizadorDeJob autorizador;

private final UsuarioAtual usuarioAtual;

AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao, AutorizadorDeJob autorizador,
UsuarioAtual usuarioAtual) {
AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao) {
this.service = service;
this.correlacao = correlacao;
this.autorizador = autorizador;
this.usuarioAtual = usuarioAtual;
}

@AutorizarJob(OperacaoJob.ACOMPANHAR)
@GetMapping(path = "/jobs/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
ResponseEntity<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
7 changes: 5 additions & 2 deletions api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package synapse.api.job;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class AutorizacaoDeJobAdvice {

@ExceptionHandler(SemPermissaoNoJobException.class)
@ExceptionHandler({ SemPermissaoNoJobException.class, AccessDeniedException.class })
ResponseEntity<ErroDto> semPermissao() {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErroDto("sem_permissao", "Você não tem acesso a este job."));
.contentType(MediaType.APPLICATION_JSON)
.body(new ErroDto("sem_permissao", "Você não tem permissão para esta ação."));
}

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

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
class AutorizacaoJobsConfig implements WebMvcConfigurer {

private final AutorizacaoJobsInterceptor interceptor;

AutorizacaoJobsConfig(AutorizacaoJobsInterceptor interceptor) {
this.interceptor = interceptor;
}

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

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

import java.util.Map;
import java.util.UUID;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;

import synapse.api.core.security.AcessoDoUsuario;
import synapse.api.core.security.UsuarioAtual;

@Component
class AutorizacaoJobsInterceptor implements HandlerInterceptor {

static final String ACESSO = "acessoAutorizadoAoJob";

private final UsuarioAtual usuarioAtual;

private final AutorizadorDeJob autorizador;

AutorizacaoJobsInterceptor(UsuarioAtual usuarioAtual, AutorizadorDeJob autorizador) {
this.usuarioAtual = usuarioAtual;
this.autorizador = autorizador;
}

@Override
public boolean preHandle(HttpServletRequest requisicao, HttpServletResponse resposta, Object handler) {
if (CorsUtils.isPreFlightRequest(requisicao)) {
return true;
}
if (!(handler instanceof HandlerMethod metodo)) {
throw new SemPermissaoNoJobException();
}
AutorizarJob politica = metodo.getMethodAnnotation(AutorizarJob.class);
if (politica == null) {
throw new SemPermissaoNoJobException();
}
AcessoDoUsuario acesso = this.usuarioAtual.obter();
Object variaveis = requisicao.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
UUID jobId = null;
if (politica.value().exigePosse() && variaveis instanceof Map<?, ?> mapa
&& mapa.get("id") instanceof String id) {
try {
jobId = UUID.fromString(id);
}
catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
this.autorizador.exigir(politica.value(), jobId, acesso);
requisicao.setAttribute(ACESSO, acesso);
return true;
}

}
20 changes: 18 additions & 2 deletions api/src/main/java/synapse/api/job/AutorizadorDeJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import java.util.List;
import java.util.UUID;

import org.jspecify.annotations.Nullable;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import synapse.api.core.security.AcessoDoUsuario;
import synapse.api.core.security.PapelDoUsuario;

@Service
class AutorizadorDeJob {
Expand All @@ -17,12 +20,25 @@ class AutorizadorDeJob {
this.jdbc = jdbc;
}

void exigirAcesso(UUID jobId, AcessoDoUsuario acesso) {
void exigir(OperacaoJob operacao, @Nullable UUID jobId, AcessoDoUsuario acesso) {
boolean permitido = switch (operacao) {
case CRIAR, LISTAR, CONSULTAR, ACOMPANHAR, CONFIRMAR_PARAMETROS, EXECUTAR_ACAO, REPROCESSAR ->
acesso.papel() == PapelDoUsuario.PROFISSIONAL_RH;
};
if (!permitido) {
throw new SemPermissaoNoJobException();
}
if (!operacao.exigePosse()) {
return;
}
if (jobId == null) {
throw new SemPermissaoNoJobException();
}
List<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())) {
if (!acesso.usuarioId().equals(donos.getFirst())) {
throw new SemPermissaoNoJobException();
}
}
Expand Down
14 changes: 14 additions & 0 deletions api/src/main/java/synapse/api/job/AutorizarJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package synapse.api.job;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface AutorizarJob {

OperacaoJob value();

}
12 changes: 2 additions & 10 deletions api/src/main/java/synapse/api/job/BuscarJobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,18 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import synapse.api.core.security.UsuarioAtual;

@RestController
class BuscarJobController {

private final BuscarJobService service;

private final AutorizadorDeJob autorizador;

private final UsuarioAtual usuarioAtual;

BuscarJobController(BuscarJobService service, AutorizadorDeJob autorizador, UsuarioAtual usuarioAtual) {
BuscarJobController(BuscarJobService service) {
this.service = service;
this.autorizador = autorizador;
this.usuarioAtual = usuarioAtual;
}

@AutorizarJob(OperacaoJob.CONSULTAR)
@GetMapping(path = "/jobs/{id}", produces = "application/json")
JobDetalhadoDto buscar(@PathVariable UUID id) {
this.autorizador.exigirAcesso(id, this.usuarioAtual.obter());
return this.service.buscar(id);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ConfirmarParametrosController {
this.service = service;
}

@AutorizarJob(OperacaoJob.CONFIRMAR_PARAMETROS)
@PostMapping(path = "/jobs/{id}/parameters", consumes = "application/json", produces = "application/json")
ResponseEntity<JobCriadoDto> confirmar(@PathVariable("id") UUID id, @RequestBody String corpo) {
JobCriadoDto job = this.service.confirmar(id, ConfirmarParametrosRequisicao.deJson(corpo));
Expand Down
14 changes: 7 additions & 7 deletions api/src/main/java/synapse/api/job/CriarJobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,24 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestAttribute;

import synapse.api.core.security.UsuarioAtual;
import synapse.api.core.security.AcessoDoUsuario;

@RestController
class CriarJobController {

private final CriarJobService service;

private final UsuarioAtual usuarioAtual;

CriarJobController(CriarJobService service, UsuarioAtual usuarioAtual) {
CriarJobController(CriarJobService service) {
this.service = service;
this.usuarioAtual = usuarioAtual;
}

@AutorizarJob(OperacaoJob.CRIAR)
@PostMapping(path = "/jobs", consumes = "application/json", produces = "application/json")
ResponseEntity<JobCriadoDto> criar(@RequestBody String corpo) {
JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), this.usuarioAtual.obter().usuarioId());
ResponseEntity<JobCriadoDto> criar(@RequestBody String corpo,
@RequestAttribute(AutorizacaoJobsInterceptor.ACESSO) AcessoDoUsuario acesso) {
JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), acesso.usuarioId());
return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job);
}

Expand Down
Loading
Loading