Większość aplikacji SaaS działa w architekturze multi-tenant, gdzie na tych samych maszynach koegzystują różne firmy-klienci. Takie rozwiązanie ma wiele zalet, jednak stawia przed inżynierami szereg wyzwań. Zobaczmy, jakie to wyzwania i jak zastosować sprawdzone wzorce, żeby im sprostać. Na koniec stworzymy aplikację Spring multi-tenant z użyciem strategii same table.
Cały kod zawarty w tym artykule znajdziecie tutaj.
Czemu multi-tenant?
Architektura single-tenant zakłada uruchamianie aplikacji na oddzielnym środowisku per klient, gdzie zasoby i dane są oddzielnie przygotowywane dla każdego klienta. Oprócz kilku zalet, jak większe bezpieczeństwo danych i możliwość dostosowania środowiska do klienta, to rozwiązanie ma też szereg wad:
- skomplikowany proces stawiania nowych środowisk,
- wysokie koszta i niepełne wysycenie zasobów.

Aby zniwelować te niekorzystne czynniki, w środowiskach chmurowych zaczęto korzystać z architektury multi-tenant, gdzie jedna infrastruktura jest dzielona pomiędzy kilku klientów-tenantów. Budowa takich systemów wprowadziła jednak szereg wyzwań, głównie związanych z bezpieczeństwem danych i stabilnością:
- noisy neighbour problem, czyli gdy jeden tenant chwilowo zabiera tyle zasobów, że pozostali mają za mało,
- separacja danych, czyli jak zminimalizować ryzyko, że jeden tenant dostanie się do danych drugiego,
- trudne obliczanie kosztu per tenant.
Problemy te jednak da się zaadresować i prawdziwe systemy SaaS prędzej czy później stają się systemami hybrydowymi, stosując minimalną separację dla większości klientów i bardziej zaawansowane rozwiązania dla dużych firm o wysokich wymaganiach.
Skoro wiemy już, z jakimi problemami mamy do czynienia, zobaczmy, jakie są strategie separacji danych w systemach multi-tenant.
Strategie separacji danych
Jest kilka głównych strategii separacji danych:
- same table (discriminator column),
- same schema, separate tables,
- same database, separate schemas,
- separate database.
Oprócz tego jest kilka innych technik, dzięki którym najbardziej wymagającym klientom możemy zapewnić szczególne bezpieczeństwo danych, jak np. Bring Your Own Key Encryption.
Prawie wszystkie główne strategie są implementowane przez Hibernate – w wersji 5.6 wszystkie oprócz same table (ciągle jednak trwają pracę nad dodaniem tego feature- polecam śledzić to zgłoszenie.
W tym artykule zobaczymy, jak możemy bez natywnego wsparcia Hibernate stworzyć aplikację Spring multi-tenant z użyciem strategii same table.
Implementacja same table
Kod implementacji znajdziecie na branchu discriminator-column.
W przypadku strategii same table separujemy dane na podstawie tzw. discriminator column, czyli kolumny zawierającej informację o przynależności danego rekordu do tenanta. Wydaje się oczywiste, że kolumna ta będzie dodana w każdej tabeli zawierającej dane encji, dodatkowo musimy przechować discriminator column w danych użytkownika.

Model
Załóżmy, że mamy aplikację, w której różni użytkownicy (User
) mogą organizować sobie pracę i tworzą zadania (Task
). Do zadań powinni mieć dostęp inni użytkownicy w ramach tej samej firmy, która jest u nas podmiotem subskrybującym usługę (Tenant
). Zatem zadanie będzie w dużym uproszczeniu wyglądać tak:
public class Task { private String title; public static Task newInstance(String title) { final Task task = new Task(); task.title = title; return task; } }
Problem
Użytkownicy danego przedsiębiorstwa mogą być tworzeni przez administratora przypisanego do tej samej firmy. Administrator powinien mieć też prawo wyświetlenia listy użytkowników, ale tylko firmy, do której należy.
Musimy więc zapewnić w naszej aplikacji separację danych należących do różnych tenantów, a także wydzielić uprawnienia administratorów.
Aby rozwiązać powyższe problemy w naszej aplikacji Spring multi-tenant użyjemy strategii same table.
Tenants
Naszą implementację zacznijmy od zdefiniowania encji przechowującej dane naszych tenantów:
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) class Tenant implements Persistable<TenantId> { @Id @Embedded private TenantId id; @Transient private boolean isNew; private String name; public static Tenant newInstance(String name) { final Tenant tenant = of(TenantId.random(), name); tenant.isNew = true; return tenant; } static Tenant of(TenantId id, String name) { final Tenant tenant = new Tenant(); tenant.id = id; tenant.name = name; return tenant; } TenantData toData() { final TenantData data = new TenantData(); data.setId(id.toString()); data.setName(name); return data; } }
Serwis, który będzie odpowiadał za tworzenie nowych tenantów:
@Service public class TenantService { private final UserService userService; private final TenantRepository tenantRepository; public TenantService(UserService userService, TenantRepository tenantRepository ) { this.userService = userService; this.tenantRepository = tenantRepository; } public TenantData createTenant(CreateTenantDto dto) { final Tenant tenant = tenantRepository.save(Tenant.newInstance(dto.getName())); try { TenantContext.override(tenant.getId()); userService.createAdmin(dto.getAdmin()); } finally { TenantContext.reset(); } return tenant.toData(); } }
W mamy tu zależność do UserService, ponieważ w momencie tworzenia nowego tenanta zakładamy mu też konto użytkownika-administratora.
Zdefiniujmy teraz interfejs, który będzie definiował obiekty należące do jakiegoś tenanta:
public interface TenantAdherent { TenantId getTenantId(); }
Dla kolejnych encji, które będziemy definiować, stwórzmy encję bazową, implementującą ten interfejs:
@MappedSuperclass @Getter @Setter(AccessLevel.PACKAGE) @NoArgsConstructor public abstract class TenantAdherentEntity implements TenantAdherent, Persistable<UUID> { @Id private UUID id; @Transient private boolean isNew; @Embedded private TenantId tenantId; }
Jeśli zastanawiasz się, skąd właściwie wzięło się isNew
lub interfejs Persistable<>
, koniecznie zajrzyj do tego artykułu.
Wrapper, którego użyliśmy, czyli TenantId
, zawiera implementację id tenanta. Dzięki adnotacji @Embedded
Hibernate potraktuje obiekt jako transparentny i zapisze do tabeli tylko jego wewnętrzne pola.
@NoArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode @Embeddable public class TenantId implements Serializable { private UUID value; public static TenantId any() { return new TenantId(); } public static TenantId of(String value) { assert value != null; TenantId tenantId = new TenantId(); tenantId.value = UUID.fromString(value); return tenantId; } public static TenantId random() { TenantId tenantId = new TenantId(); tenantId.value = UUID.randomUUID(); return tenantId; } @Override public String toString() { return Optional.of(value) .map(UUID::toString) .orElse(""); } }
Mamy tu też metodę statyczną any()
– pozwoli to nam w przyszłości określić, że działamy w kontekście każdego tenanta – np. gdy wyszukujemy użytkowników po username w trakcie logowania. Mając to wszystko, możemy zdefiniować logikę wyciągania id tenanta z kontekstu zalogowanego użytkownika:
public class TenantContext { private static final ThreadLocal<TenantId> tenantId = new ThreadLocal<>(); static TenantId getTenantId() { if (TenantContext.tenantId.get() != null) { return TenantContext.tenantId.get(); } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { return ((TenantAdherent) authentication.getPrincipal()).getTenantId(); } else { return TenantId.any(); } } public static void override(TenantId tenantId) { TenantContext.tenantId.set(tenantId); } public static void reset() { TenantContext.tenantId.remove(); } }
TenantContext
pozwala na wyciągnięcie id tenanta z SecurityContextHolder
, przechowującego dane użytkownika. W przypadku niezalogowanego użytkownika zwracamy any()
, aby móc wyszukiwać użytkownika wśród wszystkich tenantów, gdy przychodzi login request. Możemy również nadpisać id tenanta gdy wykonujemy operacje w kontekście tenanta ale bez zalogowanego użytkownika, np. gdy obsługujemy eventy z kolejki.
Users
Standardowa implementacja użytkownika, jaką mamy w Spring Security, nie uwzględnia istnienia systemów multi-tenant, będziemy więc musieli trochę przy niej „pogrzebać”. Po pierwsze, użytkownik należy do tenanta, więc musi rozszerzać TenantAdherentEntity
. Po drugie, będzie on używany jako źródło informacji dla Spring Security, więc musi implementować interfejs UserDetails
:
@Entity @Table(name = "app_user") @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) class User extends TenantAdherentEntity implements UserDetails { @Column(unique = true) private String username; private String password; @Convert(converter = AuthoritiesToStringConverter.class) private Set<Authority> authorities; public static User newInstance(String username, String password, Set<Authority> authorities) { final User user = new User(); user.username = username; user.password = password; user.authorities = authorities; return user; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public UserData toData() { final UserData data = new UserData(); data.setId(getId().toString()); data.setUsername(username); return data; } static Example<User> byUsername(String username) { User user = new User(); user.username = username; return Example.of(user); } }
Możemy teraz skonstruować implementację UserDetailsService
, którą „podłączymy” potem do flow Spring Security – od tej pory będziemy dysponować naszym nowym userem w kontekście każdego requestu, wyciągając go z SecurityContext
.
@Service public class UserService implements UserDetailsService { private final UserRepository userRepository; @Override public User loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findOne(User.byUsername(username)) .orElseThrow(() -> new UsernameNotFoundException("No user with such username!")); } }
Dlaczego używamy tutaj metody findOne(Example<User>)
, zamiast po prostu zdefiniować query method findByUsername(String)
? To jest właśnie jedna z wad tego rozwiązania, mianowicie nie obsługuje ono query methods ani native queries.
Dodajmy jeszcze metody manipulowania użytkownikami:
public UserData createUser(CreateUserDto dto) { return createUserWithAuthorities(dto, Set.of(Authority.USER)); } private UserData createUserWithAuthorities(CreateUserDto dto, Set<Authority> authorities) { final User user = User.newInstance( dto.getUsername(), passwordEncoder.encode(dto.getPassword()), authorities); return userRepository.save(user).toData(); }
public class CreateUserDto { private String username; private String password; } @Data public class UserData { private String id; private String username; }
Konfiguracja Spring Security
Teraz możemy już wpiąć nasz nowy serwis we flow Springa:
@Configuration @RequiredArgsConstructor public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { private final PasswordEncoder passwordEncoder; private final UserService userService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth .userDetailsService(userService) .passwordEncoder(passwordEncoder); } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/login").permitAll() .antMatchers("/tenants").permitAll() .anyRequest().authenticated() .and() .formLogin() .successHandler((req, resp, auth) -> resp.setStatus(OK.value())) .failureHandler((req, resp, auth) -> resp.setStatus(UNAUTHORIZED.value())) .and() .exceptionHandling() .authenticationEntryPoint((req, resp, ex) -> resp.setStatus(UNAUTHORIZED.value())) .and() .logout() .and() .csrf().disable(); } }
PasswordEncoder
wyjątkowo zdefiniujemy w osobnej klasie (żeby uniknąć zapętlenia zależności):
@Configuration public class PasswordHandlingConfiguration { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
TenantAwareRepository
Teraz dochodzimy do momentu, gdzie możemy globalnie wymusić podział danych pomiędzy tenantów. Zrobimy to przez customową implementację JpaRepository
, która dla klas dziedziczących po TenantAdherentEntity
będzie w stanie weryfikować dostęp do danych przed odpytaniem bazy danych.
@Transactional public class TenantAwareJpaRepository<T extends TenantAdherentEntity> implements JpaRepositoryImplementation<T, UUID> { private final JpaEntityInformation<T, UUID> metadata; private final SimpleJpaRepository<T, UUID> repository; public TenantAwareJpaRepository(JpaEntityInformation<T, UUID> entityInformation, EntityManager entityManager) { this.repository = new SimpleJpaRepository<>(entityInformation, entityManager); this.metadata = entityInformation; } @Override public List<T> findAll() { return repository.findAll(example()); } @Override public long count() { return repository.count(example()); } @Override public void delete(T entity) { if (entity.getTenantId().equals(TenantContext.getTenantId())) { repository.delete(entity); } } @Override public <S extends T> S save(S entity) { if (entity.getId() == null) { entity.setId(UUID.randomUUID()); entity.setNew(true); } return repository.save(tenant(entity)); } @Override public Optional<T> findById(UUID uuid) { return repository.findOne(Example.of(instance(uuid))); } @Override public boolean existsById(UUID uuid) { return exists(Example.of(instance(uuid))); } @Override public <S extends T> Optional<S> findOne(Example<S> example) { return repository.findOne(tenant(example)); } private Example<T> example() { return Example.of(instance()); } private T instance() { try { Constructor<T> constructor = this.metadata.getJavaType().getDeclaredConstructor(); constructor.setAccessible(true); return tenant(constructor.newInstance()); } catch (Exception e) { throw new RuntimeException(e); } } private T instance(UUID id) { T instance = instance(); instance.setId(id); return instance; } private <S extends T> Example<S> tenant(Example<S> example) { S entity = example.getProbe(); return Example.of(tenant(entity)); } private <S extends T> S tenant(S entity) { if (entity.getTenantId() == null) { entity.setTenantId(TenantContext.getTenantId()); } return entity; } }
Dużo się tutaj dzieje, więc zostawiłem implementację tylko kilku głównych metod, wszystko jednak opiera się na pewnym hacku, dzięki któremu zamiast szukać po id encji, szukamy po Example
, zawierającym id encji i tenanta. Niestety, nie wszystkie metody są możliwe do zaimplementowania w ten sposób. Cały kod implementacji znajdziecie w repo.
Bardzo podobne rozwiązanie – tyle, że dla MongoDB – zostało przedstawione w tym genialnym wystąpieniu Josha Cummingsa. Polecam obejrzeć całość.
Teraz pozostało nam tylko upewnić się, że podłączymy naszą implementację tylko pod repozytoria encji dziedziczących po TenantAdherentEntity
. Możemy do tego celu nadpisać domyślny JpaRepositoryFactoryBean
.
public class TenantAwareFactoryBean<R extends JpaRepository<T, I>, T, I extends Serializable> extends JpaRepositoryFactoryBean<R, T, I> { public TenantAwareFactoryBean(Class<? extends R> repositoryInterface) { super(repositoryInterface); } @Override protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) { return new TenantAwareJpaExecutorFactory(entityManager); } private static class TenantAwareJpaExecutorFactory<T, I extends Serializable> extends JpaRepositoryFactory { public TenantAwareJpaExecutorFactory(EntityManager entityManager) { super(entityManager); } @Override protected JpaRepositoryImplementation<?, ?> getTargetRepository(RepositoryInformation information, EntityManager entityManager) { if (TenantAdherentEntity.class.isAssignableFrom(information.getDomainType())) { return new TenantAwareJpaRepository(super.getEntityInformation(information.getDomainType()), entityManager); } else { return super.getTargetRepository(information, entityManager); } } @Override protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { if (TenantAdherentEntity.class.isAssignableFrom(metadata.getDomainType())) { return TenantAwareJpaRepository.class; } else { return SimpleJpaRepository.class; } } } }
Aby używać przy implementacji każdego repozytorium naszego TenantAwareFactoryBean
, musimy podłączyć je w adnotacji @EnableJpaRepositories
:
Admin priviledges
Pozostała kwestia uprawnień admina, chociaż to już najłatwiejszy problem. Doddaliśmy już adnotację @EnableMethodSecurity
, która uruchamia potrzebny nam feature – teraz pozostaje nad endpointami tylko dla adminów dodać @PreAuthorize("hasAuthority('ADMIN')")
.
Test
Stwórzmy teraz dwóch tenantów, firmę Private Co. z administratorem private-admin i Vision Co. z adminem vision-admin. Na początek spróbujmy zalogować się jako private-admin.
POST http://localhost:27001/login Content-Type: application/x-www-form-urlencoded username=private-admin&password=pass
Otrzymamy odpowiedź 200 i nowe ciasteczko sesji, co oznacza sukces, i że możemy teraz zacząć tworzyć użytkowników:
POST http://localhost:27001/users Content-Type: application/json {"username":"private-user","password":"pass"}
Zalogujmy się też na vision-admin i stwórzmy użytkownika vision-user. Użytkownicy zostali zapisani w bazie z odpowiadającymi im tenantId, i teraz jeśli wykonamy zapytanie GET /users to otrzymamy:
{ "users": [ { "id": "90f9aa29-c15d-4ae6-b77f-a721feccb50b", "username": "vision-user" }] }
Administrator Vision widzi więc tylko swoich użytkowników, co oznacza, że udało nam się odseparować dane tenantów przy zapytaniach GET.
No, może jeszcze spróbujmy DELETE /users/{userId} – jako vision-admin mogę usunąć usera z tej samej firmy, ok. A co, jeśli spróbuję usunąć private-user?
204 No Content
Czy użytkownik został usunięty? Nie możemy wnieść po kodzie odpowiedzi, gdyż jest taki sam, jak gdybyśmy usuwali użytkownika ze swojej firmy. Jest to pożądane zachowanie, ponieważ gdybyśmy zwracali tutaj inną odpowiedź, np. 403, pozwalałoby to administratorom enumerować id użytkowników innych firm. Aby zweryfikować, czy użytkownik private-user nie został usunięty, musimy spróbować się zalogować na jego dane:
POST http://localhost:27001/login Content-Type: application/x-www-form-urlencoded username=private-user&password=pass
Otrzymujemy odpowiedź:
200 OK
I teraz jesteśmy już pewni, że osiągnęlismy sukces i administrator firmy Vision nie może usunąć użytkownika firmy Private – nasza aplikacja Spring multi-tenant oparta na strategii same table skutecznie rozdziela dane użytkowników.
Podsumowanie
Rozkminiliśmy, w jaki sposób rozdzielić dane klientów SaaS w aplikacji Spring w sposób najprostszy – z punktu widzenia złożoności bazy danych – trzymając je w jednej bazie, w jednej tabeli. Nastręczamy sobie jednak przy tym pewnych problemów od strony aplikacji, nie pozwalając na korzystanie w pełni z narzędzi oferowanych przez Spring Data. Dlatego w przyszłych artykułach zaimplementujemy inne strategie rozdziału tenantów, natywnie wspierane przez Hibernate.