Arquitetura multitenancy com coluna discriminadora — Parte 1
Utilizando Java, Spring Web, Spring Data JPA (Hibernate) e Spring Security
English version: https://link.medium.com/gfmGZTsC43
Bom dia, boa tarde ou boa noite!
Esse é o meu primeiro artigo, por isso vou me apresentar: Meu nome é Filipe Martins, sou marido, pai e analista de software senior na Logicalis do Brasil.
Introdução
Em um dos meus projetos pessoais estava precisando de uma arquitetura multitenancy e foi isso que moveu tudo que fiz e apresentarei pra você nesse artigo. Vamos explicar então:
O que é multitenancy?
O termo multitenancy, em geral, é aplicado ao desenvolvimento de software para indicar uma arquitetura na qual uma única instância em execução de um aplicativo atende simultaneamente a vários tenants (clientes). Isolar informações (dados, personalizações e etc's) pertencentes aos vários tenants (clientes) é um desafio particular nesses sistemas. Isso inclui os dados pertencentes a cada tenant (cliente) armazenado no banco de dados.
Quantas formas de fazer multitenancy existem? As mais comuns são:
Base de dados separadas:
Os dados de cada tenant (cliente) são mantidos em instâncias de banco de dados separadas.
Schemas separados:
Os dados de cada tenant (cliente) são mantidos em schemas separados, porém é utilizada a mesma instância do banco de dados.
Dados particionados através de uma coluna discriminadora:
Os dados de cada tenant (cliente) são mantidos em um único schema, de uma única instância do banco de dados e os mesmos são particionados utilizando uma coluna que indica qual tenant (cliente) é dono daquele registro.
Existem prós e contras de cada abordagem que estão fora do escopo desse artigo.
Qual caso de uso eu precisava?
Bem, o título do artigo já dá spoiler, mas para o meu caso de uso precisava da terceira abordagem só que o Hibernate ainda não tem suporte nativo, como pode ser visto na documentação.
DISCRIMINATOR
Correlates to the partitioned (discriminator) approach. It is an error to attempt to open a session without a tenant identifier using this strategy. This strategy is not yet implemented and you can follow its progress via the HHH-6054 Jira issue.
E com isso começou minha busca para achar implementações, artigos e exemplos. Nas que achei, quando fui testar, era possível um tenant (cliente) ver dados de outro! Falha grave!
Bem, é ai que nasceu o projeto que disponibilizei no GitHub. O motivo de compartilhar o mesmo e escrever esse artigo são:
- Compartilhar conhecimento.
- Colocar minha implementação à prova, em todos os sentidos: Segurança, escalabilidade, legibilidade e etc's e, com isso, caso exista algo para melhorar, melhorar, simples assim.
Repositório do projeto
Rodando o projeto "AS-IS"
Para isso você vai precisar:
- Java SE Development Kit 8
- PostgresSQL 9+
- Alguma IDE de desenvolvimento que permita usar o plugin Lombok. Recomendo o excelente IntelliJ IDEA.
- Para os testes das APIs recomendo usar o Postman.
A forma mais fácil de subir o PostgreSQL é utilizar o Docker. Com o mesmo instalado só executar o comando docker run --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres
Agora é só executar a classe DemoApplication
. No "IntelliJ IDEA" basta clicar com o botão direto nela e clicar em Run 'DemoApplication.main()'
.
Principais pontos do desenvolvimento do projeto
Se você chegou aqui, não quis só pegar o código pronto e utilizar, então vamos passar pelos principais pontos juntos.
Tenantable
é uma classe cuja as informações de mapeamento são aplicadas para as entidades que herdam dela, sendo assim, não possui uma tabela no banco de dados para a mesma. Fazemos isso anotando a classe com @MappedSuperclass
.
- Linhas 3 a 6: Essa é parte responsável por inserir filtros nas consultas (selects) de todas as classes que herdam dela. Veremos mais pra frente como esses filtros serão populados automaticamente.
- Linha 7: Classe responsável por "ouvir" as operações realizadas em todas as classes que herdam dela. Vejamos abaixo a implementação da mesma.
- Linhas 6 a 10: Antes de um insert (
@PrePersist)
ou update (@PreUpdate)
o métodoprePersistOrUpdate
será executado. Caso o objeto seja uma instância da classeTenantable
o atributotenantId
terá como valor otenantId
dotenantContext
.
Resumindo: Aqui estamos garantindo que dados inseridos ou atualizados estejam no tenant do usuário autenticado. - Linhas 14 a 17: Antes de um delete (
@PreRemove)
o métodopreRemove
será executado e caso o objeto seja uma instância da classeTenantable
E o atributotenantId
do objeto que se está tentando apagar for diferente do atributotenantId
dotenantContext
, um erroEntityNotFoundException
será lançado. Caso contrário, o mesmo é apagado com sucesso.
Resumindo: Aqui estamos nos protegendo de tentativas de um usuário de um tenant apagar dados de outro tenant.
Citamos acima a classe TenantContext
. Vamos ver qual o propósito da mesma abaixo.
Simples não? Ela é responsável por encapsular a forma que recuperamos qual é o tenantId
do usuário autenticado.
O mesmo é obtido do objeto Authentication
do SecurityContextHolder
, ou seja, adicionado através de um filtro do Spring Security. Vejamos como isso é feito abaixo.
A classe acima é um filtro do Spring Security. Existem vários disponíveis dependendo do caso de uso, mas para esse artigo vamos usar essa que simula um usuário autenticado com o e-mail foo@bar.com
do tenant 1
.
DICA: É aqui que podemos colocar a lógica de obter as credencias do usuário, principalmente uma muita usada em APIs REST, o JWT, mas isso está fora do escopo desse artigo…mas quem sabe no próximo, né? ; )
A classe acima é quem habilita e permite a configuração da segurança fornecida pelo Spring Security.
- Linha 10: O filtro customizado que criamos é colocado na cadeia de filtros de segurança.
DICA: É aqui que podemos definir regras de autorização de acesso a rotas de nossas APIs, utilizando antMatchers
, mas isso fica para um próximo artigo, ou só pesquisar e DYI! ; )
Agora uma das classes mais interessantes. Ela utiliza Spring AOP, que você pode ler com mais detalhes em: https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop
- Linha 11: Quando algum método que comece com
find
, com qualquer número de argumentos, de algum repositório que herda deTenantableRepository
for executado, então o métodobeforeFindOfTenantableRepository
será executado. - Linhas 13 a 16: Pegamos a sessão de persistência, habilitamos o filtro que vimos na primeira classe (
Tenantable)
e preenchemos o mesmo com o valor dotenantId
do usuário autenticado.
Resumindo: Quando um SELECT for feito em um repositório que herda de TenantableRepository
, a cláusula "WHERE tenant_id = ?" será inserida no SELECT e a ? será substituída pelo ID do tenant do usuário autenticado.
Na linha 11, temos uma referência a classe TenantableRepository
. Vamos ver abaixo um motivo extra de sua existência além de permitir o que vimos até aqui.
Essa é a classe que todo repositório precisa herdar se quiser lidar com entidades multi tenant. Por último, vamos olhar a classe UserService
, cujo repositório UserRepository
herda da classe acima.
Quase todos os métodos dessa classe falam por si só, por isso falaremos só do updateById
. O motivo de precisarmos falar sobre esse ponto em específico é que alguém pode perguntar:
Hey! Por que você não usou o método
findById
que já vem pronto no Spring Data JPA?
Seria ótimo, mas não foi possível por conta disso aqui: https://stackoverflow.com/questions/45169783/hibernate-filter-is-not-applied-for-findone-crud-operation
Explicando rapidamente: A implementação do findById
não faz uma query de entidade e sim uma busca direta, logo, os filtros que queremos que sejam preenchidos automaticamente, nesse caso, não são.
Resumindo: Ao procurar a entidade com um findById
o mesmo não fará um WHERE por tenantId
no SELECT, podendo assim pegar um registro de um outro tenant para fazer UPDATE. E isso não pode acontecer, de jeito nenhum!
Voltando ao código da classe UserService
.
- Linha 16: Utilizamos o método
findOneById
, que agora sim é uma query de entidade, que começa comfind
e que nossa classeTenantAspect
vai conseguir interceptar e preencher o filtrotenantId
. Caso o usuário esteja tentando atualizar registros de outro tenant, simplesmente vai receber umEntityNotFoundException
.
Conclusão
Vimos os principais pontos do projeto que permite ter uma aplicação multitenancy usando uma coluna discriminadora, que na maioria dos casos, e no meu, se chama tenant_id
.
Testando
Você se lembra da classe TenantAuthorizationFilter
?
É nela que simulamos que o usuário foo@bar.com
do tenant 1
está autenticado no sistema, então:
- Crie quantos tenants quiser.
- Liste os mesmos e pegue o
id
de algum. - Coloque o
id
escolhido no segundo parâmetro,tenantId
, do construtor da classeTenantAuthenticationToken
. - Reinicie a aplicação para simular a autenticação com o tenant escolhido no passo 2.
- Crie usuários.
- Repita o passo 2, pegue um outro
id
e repita os passos 3 e 4. - Agora tente listar, atualizar ou apagar um usuário de outro tenant.
- Faça esses e outros testes que desejar.
Lista de APIs disponíveis e exemplos de body (JSON)
Tenants
POST http://localhost:8080/api/tenants
Exemplo de body: {“name”: “Tenant 1”}
GET: http://localhost:8080/api/tenants
Users
POST http://localhost:8080/api/users
Exemplo de body: {“name”: “User 1”}
GET http://localhost:8080/api/users
PATCH http://localhost:8080/api/users/1
Exemplo de Body: {"name": "User 1 v2"}
DELETE: http://localhost:8080/api/users/1
Bônus do projeto
Implementei funcionalidades, listadas abaixo, que não fazem parte do escopo desse artigo e que o deixariam maior do que já está.
JPA Auditing
: Faz com que todas as entidades que herdam deAuditable
tenham os camposcreatedBy e createdDate
populados ao inserir uma entidade elastModifiedBy e lastModifiedDate
populados ao atualizar uma entidade, automaticamente.
No arquivo "application.properties"
- Linhas 1 a 3: Configuração da conexão com o banco de dados através de variáveis de ambiente.
- Linha 5: Configuração do
Allowed Origins
doCORS
através de variável de ambiente.
Bem, é isso pessoal! Espero que tenham gostado! A segunda e última parte do artigo está disponível aqui.