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
- Autenticação: O usuário é autenticado via Google (como nas partes anteriores).
-
Contexto do Tenant: O
TenantFilterextrai osubdo usuário autenticado e define oTenantContext. -
Persistência: Já feita na Parte 2, o
tenant_idestá no banco como parte da entidadeUser. -
Resposta: O
HomeControllerusa oTenantContextpara retornar otenant_idjunto com o JWT. -
Limpeza: O
TenantFilterlimpa oTenantContextapó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);
}
}
}
-
Nota: Nenhuma mudança é necessária, pois o
CustomOAuth2UserServicejá 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;
}
}
-
loadUser(): Agora define oTenantContextcom osubdurante a persistência, embora isso seja mais útil na Parte 3 para o filtro. Ofinallylimpa 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
}
}
-
ThreadLocal: Usa um armazenamento por thread para isolar otenant_identre 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
}
}
}
-
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 osubcomotenant_id. -
TenantContext.setCurrentTenant(): Define otenant_idpara uso durante a requisição. -
filterChain.doFilter(): Passa a requisição adiante (ex.: para oHomeController). -
finally: Limpa oTenantContextpara 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!";
}
}
-
home(): Inclui otenantIdno retorno, obtido doTenantContextdefinido peloTenantFilter.
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..."
}
Detalhamento Técnico do Fluxo
-
Filtro: O
TenantFilteré registrado na cadeia de filtros do Spring (FilterChainProxy) e executado antes do controlador. Ele usa oSecurityContextHolderpara acessar oOAuth2Userautenticado. -
Contexto: O
TenantContextusaThreadLocalpara garantir que otenant_idseja ú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. Otenant_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 { ... }
- 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);
}
}
Outras Formas de Fazer
-
Multi-Tenancy por Schema:
-
Como: Usa
multiTenancy: SCHEMAeTenantIdentifierResolverpara criar schemas por tenant. - Vantagens: Isolamento físico, maior segurança.
- Desvantagens: Complexidade de gerenciamento.
-
Como: Usa
-
Multi-Tenancy por Banco Separado:
-
Como: Configura um
DataSourcedinâmico por tenant. - Vantagens: Isolamento completo.
- Desvantagens: Alto custo de infraestrutura.
-
Como: Configura um
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)