Arquitetura multitenancy com coluna discriminadora — Parte 1

Utilizando Java, Spring Web, Spring Data JPA (Hibernate) e Spring Security

Filipe Martins
7 min readFeb 13, 2020

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étodo prePersistOrUpdate será executado. Caso o objeto seja uma instância da classe Tenantable o atributo tenantId terá como valor o tenantId do tenantContext.
    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étodo preRemove será executado e caso o objeto seja uma instância da classe Tenantable E o atributo tenantId do objeto que se está tentando apagar for diferente do atributo tenantId do tenantContext, um erro EntityNotFoundException 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 de TenantableRepository for executado, então o método beforeFindOfTenantableRepository 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 do tenantId 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 com find e que nossa classe TenantAspect vai conseguir interceptar e preencher o filtro tenantId. Caso o usuário esteja tentando atualizar registros de outro tenant, simplesmente vai receber um EntityNotFoundException.

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:

  1. Crie quantos tenants quiser.
  2. Liste os mesmos e pegue o id de algum.
  3. Coloque o id escolhido no segundo parâmetro, tenantId, do construtor da classe TenantAuthenticationToken.
  4. Reinicie a aplicação para simular a autenticação com o tenant escolhido no passo 2.
  5. Crie usuários.
  6. Repita o passo 2, pegue um outro id e repita os passos 3 e 4.
  7. Agora tente listar, atualizar ou apagar um usuário de outro tenant.
  8. 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 de Auditable tenham os campos createdBy e createdDate populados ao inserir uma entidade e lastModifiedBy 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 do CORS 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.

--

--