DEV Community

Uiratan Cavalcante
Uiratan Cavalcante

Posted on

Parte 3: Adicionando Multi-Tenancy por Coluna Discriminadora

Introdução

Multi-tenancy é uma técnica que permite que uma única aplicação sirva a múltiplos "tenants" (inquilinos), isolando seus dados de forma lógica ou física. Surgiu com o crescimento do SaaS na década de 2000 e é essencial em plataformas como Slack e HubSpot. Existem três abordagens principais no Hibernate: SCHEMA (schemas separados), DATABASE (bancos separados), e DISCRIMINATOR (coluna discriminadora). Aqui, usaremos a estratégia de Coluna Discriminadora, onde uma coluna tenant_id na tabela users identifica o tenant de cada registro.

Nesta parte, vamos adicionar multi-tenancy ao sistema da Parte 2, usando o tenant_id já persistido para gerenciar o contexto do tenant por requisição. Implementaremos um TenantContext para armazenar o tenant_id atual e um TenantFilter para defini-lo dinamicamente, mantendo o H2 em memória como banco. O isolamento será lógico (baseado em tenant_id), mas não automático — isso dependerá de ajustes manuais em queries ou filtros adicionais, que mencionaremos como opcional.


Configuração Inicial

pom.xml

Mesma configuração da Parte 2 — sem alterações.

application.yml

Mesma configuração da Parte 2 — sem alterações, pois não usamos multiTenancy: SCHEMA ou TenantIdentifierResolver para o Hibernate neste caso.


Resumo do Fluxo Geral

  1. Autenticação: O usuário é autenticado via Google (como nas partes anteriores).
  2. Contexto do Tenant: O TenantFilter extrai o sub do usuário autenticado e define o TenantContext.
  3. Persistência: Já feita na Parte 2, o tenant_id está no banco como parte da entidade User.
  4. Resposta: O HomeController usa o TenantContext para retornar o tenant_id junto com o JWT.
  5. Limpeza: O TenantFilter limpa o TenantContext após a requisição.

Passo a Passo

1. Manter o SecurityConfig como Está
package com.example.slotty.security;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Autowired;
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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/oauth2/**", "/login/oauth2/**", "/error", "/favicon.ico", "/").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/home", true)
                .failureUrl("/error?error=true")
                .userInfoEndpoint(userInfo -> userInfo.oidcUserService(customOAuth2UserService))
            )
            .logout(logout -> logout.logoutSuccessUrl("/").permitAll())
            .build();
    }

    @Bean
    public JwtEncoder jwtEncoder() {
        JWKSource<SecurityContext> jwkSource = getJwkSource();
        return new NimbusJwtEncoder(jwkSource);
    }

    private JWKSource<SecurityContext> getJwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    private KeyPair generateRsaKey() {
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException("Erro ao gerar RSA KeyPair", ex);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Nota: Nenhuma mudança é necessária, pois o CustomOAuth2UserService já foi integrado na Parte 2.
2. Manter a Entidade User e o UserRepository

Sem alterações — a entidade e o repositório da Parte 2 já incluem tenant_id.

3. Atualizar o CustomOAuth2UserService para Usar TenantContext
package com.example.slotty.security;

import com.example.slotty.tenant.TenantContext;
import com.example.slotty.user.User;
import com.example.slotty.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;

@Service
public class CustomOAuth2UserService extends OidcUserService {
    private final UserRepository userRepository;

    @Autowired
    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(userRequest);
        String id = oidcUser.getSubject();
        String email = oidcUser.getEmail();
        String name = oidcUser.getFullName();

        TenantContext.setCurrentTenant(id); // Define o tenant temporariamente

        try {
            userRepository.findById(id)
                .orElseGet(() -> {
                    User newUser = new User();
                    newUser.setId(id);
                    newUser.setEmail(email);
                    newUser.setName(name);
                    newUser.setTenantId(id);
                    return userRepository.save(newUser);
                });
        } finally {
            TenantContext.clear(); // Limpa após a operação
        }

        return oidcUser;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • loadUser(): Agora define o TenantContext com o sub durante a persistência, embora isso seja mais útil na Parte 3 para o filtro. O finally limpa o contexto.
4. Criar o TenantContext
package com.example.slotty.tenant;

public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();

    public static void setCurrentTenant(String tenant) {
        currentTenant.set(tenant); // Armazena o tenant_id na thread atual
    }

    public static String getCurrentTenant() {
        return currentTenant.get(); // Retorna o tenant_id atual
    }

    public static void clear() {
        currentTenant.remove(); // Remove o tenant_id da thread
    }
}
Enter fullscreen mode Exit fullscreen mode
  • ThreadLocal: Usa um armazenamento por thread para isolar o tenant_id entre requisições concorrentes.
  • setCurrentTenant, getCurrentTenant, clear: Métodos para manipular o contexto do tenant.
5. Criar o TenantFilter
package com.example.slotty.tenant;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() instanceof OAuth2User) {
            OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
            String tenantId = oAuth2User.getAttribute("sub");
            TenantContext.setCurrentTenant(tenantId); // Define o tenant para a requisição
        }
        try {
            filterChain.doFilter(request, response); // Executa o próximo filtro ou controlador
        } finally {
            TenantContext.clear(); // Limpa o contexto
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • doFilterInternal:
    • SecurityContextHolder.getContext().getAuthentication(): Acessa o objeto de autenticação preenchido pelo Spring Security.
    • instanceof OAuth2User: Verifica se o principal é um usuário OAuth2/OIDC.
    • getAttribute("sub"): Extrai o sub como tenant_id.
    • TenantContext.setCurrentTenant(): Define o tenant_id para uso durante a requisição.
    • filterChain.doFilter(): Passa a requisição adiante (ex.: para o HomeController).
    • finally: Limpa o TenantContext para evitar interferência entre threads.
6. Atualizar o HomeController
package com.example.slotty.controller;

import com.example.slotty.tenant.TenantContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.HashMap;
import java.util.Map;

@RestController
public class HomeController {

    @Autowired
    private JwtEncoder jwtEncoder;

    @GetMapping("/home")
    public ResponseEntity<Map<String, String>> home(@AuthenticationPrincipal OidcUser oidcUser) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("self")
            .issuedAt(now)
            .expiresAt(now.plusSeconds(3600))
            .subject(oidcUser.getSubject())
            .claim("email", oidcUser.getEmail())
            .claim("name", oidcUser.getFullName())
            .build();

        String jwt = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();

        Map<String, String> response = new HashMap<>();
        response.put("message", "Welcome " + oidcUser.getFullName());
        response.put("tenantId", TenantContext.getCurrentTenant()); // Adiciona o tenant_id
        response.put("jwt", jwt);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/")
    public String root() {
        return "Bem-vindo à página inicial!";
    }
}
Enter fullscreen mode Exit fullscreen mode
  • home(): Inclui o tenantId no retorno, obtido do TenantContext definido pelo TenantFilter.
7. Testar o Sistema
  • Execute mvn spring-boot:run.
  • Acesse http://localhost:8080/home.
  • Após login, você verá:
  {
    "message": "Welcome Uira Teste",
    "tenantId": "104976604334039049008",
    "jwt": "eyJraWQiOiJhYjNkZGYy..."
  }
Enter fullscreen mode Exit fullscreen mode

Detalhamento Técnico do Fluxo

  • Filtro: O TenantFilter é registrado na cadeia de filtros do Spring (FilterChainProxy) e executado antes do controlador. Ele usa o SecurityContextHolder para acessar o OAuth2User autenticado.
  • Contexto: O TenantContext usa ThreadLocal para garantir que o tenant_id seja único por requisição, essencial em um servidor multithread como o Tomcat.
  • Isolamento: Como estamos usando multi-tenancy por coluna discriminadora sem filtros automáticos no Hibernate, o isolamento de dados não é aplicado nas queries do UserRepository. O tenant_id é apenas armazenado e retornado, mas não restringe acesso automaticamente.

Opcional para Isolamento Completo:

  • Adicionar um filtro Hibernate:
  @Entity
  @FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
  @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
  public class User { ... }
Enter fullscreen mode Exit fullscreen mode
  • Habilitar o filtro no repositório:
  @Repository
  public interface UserRepository extends JpaRepository<User, String> {
      default void enableTenantFilter(String tenantId) {
          Session session = entityManager.unwrap(Session.class);
          session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
      }
  }
Enter fullscreen mode Exit fullscreen mode

Outras Formas de Fazer

  1. Multi-Tenancy por Schema:
    • Como: Usa multiTenancy: SCHEMA e TenantIdentifierResolver para criar schemas por tenant.
    • Vantagens: Isolamento físico, maior segurança.
    • Desvantagens: Complexidade de gerenciamento.
  2. Multi-Tenancy por Banco Separado:
    • Como: Configura um DataSource dinâmico por tenant.
    • Vantagens: Isolamento completo.
    • Desvantagens: Alto custo de infraestrutura.

Muito obrigado por chegar ao fim desta jornada sobre multi-tenancy por coluna discriminadora no Spring Boot! 🎉

Espero que as explicações técnicas tenham esclarecido como gerenciar tenants de forma lógica e simples. O que achou dessa solução com TenantContext e TenantFilter? 🧠

Deixe seu comentário com opiniões ou sugestões abaixo ✍️, e sinta-se à vontade para compartilhar este post com outros desenvolvedores interessados em arquiteturas multi-tenant 📲.

Até a próxima aventura tecnológica! 🚀

Top comments (0)