├── .gitignore
├── LICENSE
├── README.md
├── docker-compose.yml
├── docker
├── .env
└── rls-api
│ └── Dockerfile
├── pom.xml
├── sql
├── create_data.sql
└── create_database.sql
└── src
└── main
├── java
└── de
│ └── bytefish
│ └── multitenancy
│ ├── SampleSpringApplication.java
│ ├── async
│ ├── AsyncConfig.java
│ └── TenantAwareTaskDecorator.java
│ ├── conf
│ ├── ApplicationConfiguration.java
│ └── TenantConfiguration.java
│ ├── core
│ ├── TenantAware.java
│ ├── TenantListener.java
│ └── ThreadLocalStorage.java
│ ├── datasource
│ └── TenantAwareRoutingSource.java
│ ├── model
│ ├── Address.java
│ ├── Customer.java
│ ├── CustomerAddress.java
│ └── Tenant.java
│ ├── repositories
│ ├── IAddressRepository.java
│ ├── ICustomerAddressRepository.java
│ └── ICustomerRepository.java
│ └── web
│ ├── configuration
│ └── WebMvcConfig.java
│ ├── controllers
│ └── CustomerController.java
│ ├── converter
│ └── Converters.java
│ ├── interceptors
│ └── TenantNameInterceptor.java
│ └── model
│ ├── AddressDto.java
│ └── CustomerDto.java
└── resources
├── application-docker.yml
├── application.yml
└── logback.xml
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.idea
3 | target
4 | .m2
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Philipp Wagner
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Providing Multitenancy with Spring Boot #
2 |
3 | ## Project ##
4 |
5 | This project is an example project for Multi Tenancy using Row Level Security:
6 |
7 | * https://bytefish.de/blog/spring_boot_multitenancy_using_rls/
8 |
9 | ### Example ###
10 |
11 | You can use `docker compose` to create the PostgreSQL database and start the Spring Boot application:
12 |
13 | ```
14 | docker compose --profile dev up
15 | ```
16 |
17 | The Postgres Database currently has a customer for each Tenant:
18 |
19 | ```sql
20 | > select * from multitenant.customer
21 |
22 | customer_id | first_name | last_name | tenant_name
23 | ----------------+---------------+---------------+--------------
24 | 1 | Philipp | Wagner | tenant_a
25 | 2 | John | Wick | tenant_b
26 | (2 rows)
27 | ```
28 |
29 | The list of customers for `tenant_a` only contains "Philipp Wagner", as expected:
30 |
31 | ```
32 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers
33 |
34 | [{"id":1,"firstName":"Philipp","lastName":"Wagner","addresses":[{"id":1,"name":"Philipp Wagner","street":"Fakestreet 1","postalcode":"12345","city":"Faketown","country":"Germany"}]}]
35 | ```
36 |
37 | And the list of customers for `tenant_b` only contains "John Wick", again as expected:
38 |
39 | ```
40 | >curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers
41 |
42 | [{"id":2,"firstName":"John","lastName":"Wick","addresses":[{"id":2,"name":"John Wick","street":"Fakestreet 55","postalcode":"00000","city":"Fakecity","country":"USA"}]}]
43 | ```
44 |
45 | We now insert a new customer for Tenant `tenant_a`:
46 |
47 | ```
48 | > curl -H "X-TenantID: tenant_a" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Max\", \"lastName\" : \"Mustermann\"}" http://localhost:8080/customers
49 |
50 | {"id":38187,"firstName":"Max","lastName":"Mustermann","addresses":[]}
51 | ```
52 |
53 | Getting a list of all customers for `tenant_a` will now return two customers:
54 |
55 | ```
56 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers
57 |
58 | [{"id":1,"firstName":"Philipp","lastName":"Wagner"},{"id":38187,"firstName":"Max","lastName":"Mustermann"}]
59 | ```
60 |
61 | While requesting a list of all customers for `tenant_b` returns John Wick only:
62 |
63 | ```
64 | > curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers
65 |
66 | [{"id":2,"firstName":"John","lastName":"Wick"}]
67 | ```
68 |
69 | We can now insert a customer for `tenant_b`:
70 |
71 | ```
72 | > curl -H "X-TenantID: tenant_b" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Hans\", \"lastName\" : \"Wurst\"}" http://localhost:8080/customers
73 |
74 | {"id":38188,"firstName":"Hans","lastName":"Wurst","addresses":[]}
75 | ```
76 |
77 | Querying the `tenant_a` database still returns "Philipp Wagner" and "Max Mustermann":
78 |
79 | ```
80 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers
81 |
82 | [{"id":1,"firstName":"Philipp","lastName":"Wagner"},{"id":38187,"firstName":"Max","lastName":"Mustermann"}]
83 | ```
84 |
85 | While querying as `tenant_b` now returns "John Wick" and "Hans Wurst":
86 |
87 | ```
88 | > curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers
89 |
90 | [{"id":2,"firstName":"John","lastName":"Wick"},{"id":38188,"firstName":"Hans","lastName":"Wurst"}]
91 | ```
92 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | services:
3 |
4 | services:
5 | postgres:
6 | image: postgres:16
7 | container_name: postgres
8 | networks:
9 | - services
10 | ports:
11 | - "5432:5432"
12 | environment:
13 | - POSTGRES_USER=postgres
14 | - POSTGRES_PASSWORD=password
15 | - POSTGRES_DB=sampledb
16 | volumes:
17 | - ./sql/create_database.sql:/docker-entrypoint-initdb.d/1-create_database.sql
18 | - ./sql/create_data.sql:/docker-entrypoint-initdb.d/2-create_data.sql
19 | healthcheck:
20 | test: [ "CMD-SHELL", "pg_isready -U postgres" ]
21 | interval: 5s
22 | timeout: 5s
23 | retries: 5
24 | profiles: ["postgres", "dev"]
25 | rls-api:
26 | depends_on:
27 | - postgres
28 | build:
29 | context: .
30 | dockerfile: ./docker/rls-api/Dockerfile
31 | networks:
32 | - services
33 | restart: on-failure
34 | env_file: ./docker/.env
35 | profiles: ["api", "dev"]
36 | ports:
37 | - "8080:8080"
38 | environment:
39 | - SPRING_PROFILES_ACTIVE=docker
40 | volumes:
41 | - /docker/.m2:/root/.m2
42 | stdin_open: true
43 | tty: true
--------------------------------------------------------------------------------
/docker/.env:
--------------------------------------------------------------------------------
1 | ELASTIC_HOSTNAME=es01
2 | ELASTIC_USERNAME=elastic
3 | ELASTIC_PASSWORD=secret
4 | ELASTIC_PORT=9200
5 | ELASTIC_SECURITY=true
6 | ELASTIC_SCHEME=https
7 | ELASTIC_VERSION=8.14.1
8 |
--------------------------------------------------------------------------------
/docker/rls-api/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM maven:3.8.5-openjdk-17
2 |
3 | WORKDIR /rls-api
4 |
5 | COPY pom.xml pom.xml
6 | COPY src src
7 |
8 | RUN mvn clean install
9 |
10 | CMD mvn spring-boot:run
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 |
8 | org.springframework.boot
9 | spring-boot-starter-parent
10 | 3.3.2
11 |
12 |
13 |
14 | de.bytefish
15 | multitenancy
16 | 0.1
17 |
18 |
19 |
20 |
21 | org.springframework.boot
22 | spring-boot-maven-plugin
23 |
24 |
25 |
26 |
27 |
28 |
29 | UTF-8
30 | 17
31 |
32 |
33 |
34 |
35 |
36 | org.springframework.boot
37 | spring-boot-starter-web
38 |
39 |
40 |
41 | org.springframework.boot
42 | spring-boot-starter-jdbc
43 |
44 |
45 |
46 | org.springframework.boot
47 | spring-boot-starter-data-jpa
48 |
49 |
50 |
51 | org.postgresql
52 | postgresql
53 | 42.7.3
54 |
55 |
56 |
57 | com.zaxxer
58 | HikariCP
59 | 5.1.0
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/sql/create_data.sql:
--------------------------------------------------------------------------------
1 | DO $$
2 | BEGIN
3 |
4 | ----------------------------------------------
5 | -- Create the Sample Data for Tenant A --
6 | ----------------------------------------------
7 | INSERT INTO multitenant.customer(customer_id, first_name, last_name, tenant_name)
8 | VALUES
9 | (1, 'Philipp', 'Wagner', 'tenant_a')
10 | ON CONFLICT DO NOTHING;
11 |
12 | INSERT INTO multitenant.address(address_id, name, street, postalcode, city, country, tenant_name)
13 | VALUES
14 | (1, 'Philipp Wagner', 'Fakestreet 1', '12345', 'Faketown', 'Germany', 'tenant_a')
15 | ON CONFLICT DO NOTHING;
16 |
17 | INSERT INTO multitenant.customer_address(customer_id, address_id, tenant_name)
18 | VALUES
19 | (1, 1, 'tenant_a')
20 | ON CONFLICT DO NOTHING;
21 |
22 | ----------------------------------------------
23 | -- Create the Sample Data for Tenant B --
24 | ----------------------------------------------
25 | INSERT INTO multitenant.customer(customer_id, first_name, last_name, tenant_name)
26 | VALUES
27 | (2, 'John', 'Wick', 'tenant_b')
28 | ON CONFLICT DO NOTHING;
29 |
30 | INSERT INTO multitenant.address(address_id, name, street, postalcode, city, country, tenant_name)
31 | VALUES
32 | (2, 'John Wick', 'Fakestreet 55', '00000', 'Fakecity', 'USA', 'tenant_b')
33 | ON CONFLICT DO NOTHING;
34 |
35 | INSERT INTO multitenant.customer_address(customer_id, address_id, tenant_name)
36 | VALUES
37 | (2, 2, 'tenant_b')
38 | ON CONFLICT DO NOTHING;
39 |
40 | END;
41 | $$;
--------------------------------------------------------------------------------
/sql/create_database.sql:
--------------------------------------------------------------------------------
1 | DO $$
2 | BEGIN
3 |
4 | ---------------------------
5 | -- Create the tenants --
6 | ---------------------------
7 | IF NOT EXISTS (
8 | SELECT FROM pg_catalog.pg_roles
9 | WHERE rolname = 'tenant_a') THEN
10 |
11 | CREATE ROLE tenant_a LOGIN PASSWORD 'tenant_a';
12 |
13 | END IF;
14 |
15 | IF NOT EXISTS (
16 | SELECT FROM pg_catalog.pg_roles
17 | WHERE rolname = 'tenant_b') THEN
18 |
19 | CREATE ROLE tenant_b LOGIN PASSWORD 'tenant_b';
20 |
21 | END IF;
22 |
23 | ---------------------------
24 | -- Create the Schema --
25 | ---------------------------
26 | CREATE SCHEMA IF NOT EXISTS multitenant;
27 |
28 | ----------------------------
29 | -- Create Sequences --
30 | ----------------------------
31 | CREATE SEQUENCE IF NOT EXISTS multitenant.customer_seq
32 | start 38187
33 | increment 1
34 | NO MAXVALUE
35 | CACHE 1;
36 |
37 | CREATE SEQUENCE IF NOT EXISTS multitenant.address_seq
38 | start 38187
39 | increment 1
40 | NO MAXVALUE
41 | CACHE 1;
42 |
43 | ----------------------------
44 | -- Create the Tables --
45 | ----------------------------
46 | CREATE TABLE IF NOT EXISTS multitenant.customer
47 | (
48 | customer_id integer default nextval('multitenant.customer_seq'),
49 | first_name VARCHAR(255) NOT NULL,
50 | last_name VARCHAR(255) NOT NULL,
51 | tenant_name VARCHAR(255) NOT NULL,
52 | CONSTRAINT customer_pkey
53 | PRIMARY KEY (customer_id)
54 |
55 | );
56 |
57 | CREATE TABLE IF NOT EXISTS multitenant.address
58 | (
59 | address_id integer default nextval('multitenant.address_seq'),
60 | name VARCHAR(255) NOT NULL,
61 | street VARCHAR(255) NULL,
62 | postalcode VARCHAR(255) NULL,
63 | city VARCHAR(255) NULL,
64 | country VARCHAR(255) NULL,
65 | tenant_name VARCHAR(255) NOT NULL,
66 | CONSTRAINT address_pkey
67 | PRIMARY KEY (address_id)
68 |
69 | );
70 |
71 | CREATE TABLE IF NOT EXISTS multitenant.customer_address
72 | (
73 | customer_id integer NOT NULL,
74 | address_id integer NOT NULL,
75 | tenant_name VARCHAR(255) NOT NULL,
76 | CONSTRAINT fk_customer_address_customer
77 | FOREIGN KEY(customer_id)
78 | REFERENCES multitenant.customer(customer_id),
79 | CONSTRAINT fk_customer_address_address
80 | FOREIGN KEY(address_id)
81 | REFERENCES multitenant.address(address_id)
82 | );
83 |
84 | ---------------------------
85 | -- Enable RLS --
86 | ---------------------------
87 | ALTER TABLE multitenant.customer
88 | ENABLE ROW LEVEL SECURITY;
89 |
90 | ALTER TABLE multitenant.address
91 | ENABLE ROW LEVEL SECURITY;
92 |
93 | ALTER TABLE multitenant.customer_address
94 | ENABLE ROW LEVEL SECURITY;
95 |
96 | ---------------------------
97 | -- Create the RLS Policy --
98 | ---------------------------
99 | DROP POLICY IF EXISTS tenant_customer_isolation_policy ON multitenant.customer;
100 | DROP POLICY IF EXISTS tenant_address_isolation_policy ON multitenant.address;
101 | DROP POLICY IF EXISTS tenant_customer_address_isolation_policy ON multitenant.customer_address;
102 |
103 | CREATE POLICY tenant_customer_isolation_policy ON multitenant.customer
104 | USING (tenant_name = current_user);
105 |
106 | CREATE POLICY tenant_address_isolation_policy ON multitenant.address
107 | USING (tenant_name = current_user);
108 |
109 | CREATE POLICY tenant_customer_address_isolation_policy ON multitenant.customer_address
110 | USING (tenant_name = current_user);
111 |
112 | --------------------------------
113 | -- Grant Access to the Schema --
114 | --------------------------------
115 | GRANT USAGE ON SCHEMA multitenant TO tenant_a;
116 | GRANT USAGE ON SCHEMA multitenant TO tenant_b;
117 |
118 | -------------------------------------------------------
119 | -- Grant Access to multitenant.customer for Tenant A --
120 | -------------------------------------------------------
121 | GRANT ALL ON SEQUENCE multitenant.customer_seq TO tenant_a;
122 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer TO tenant_a;
123 |
124 | GRANT ALL ON SEQUENCE multitenant.address_seq TO tenant_a;
125 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.address TO tenant_a;
126 |
127 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer_address TO tenant_a;
128 |
129 | -------------------------------------------------------
130 | -- Grant Access to multitenant.customer for Tenant B --
131 | -------------------------------------------------------
132 | GRANT ALL ON SEQUENCE multitenant.customer_seq TO tenant_b;
133 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer TO tenant_b;
134 |
135 | GRANT ALL ON SEQUENCE multitenant.address_seq TO tenant_b;
136 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.address TO tenant_b;
137 |
138 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer_address TO tenant_b;
139 |
140 | END;
141 | $$;
--------------------------------------------------------------------------------
/src/main/java/de/bytefish/multitenancy/SampleSpringApplication.java:
--------------------------------------------------------------------------------
1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information.
2 |
3 | package de.bytefish.multitenancy;
4 |
5 | import com.zaxxer.hikari.HikariDataSource;
6 | import de.bytefish.multitenancy.conf.ApplicationConfiguration;
7 | import de.bytefish.multitenancy.conf.TenantConfiguration;
8 | import de.bytefish.multitenancy.datasource.TenantAwareRoutingSource;
9 | import de.bytefish.multitenancy.model.Tenant;
10 | import org.springframework.boot.SpringApplication;
11 | import org.springframework.boot.autoconfigure.SpringBootApplication;
12 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
13 | import org.springframework.context.annotation.Bean;
14 | import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
15 | import org.springframework.scheduling.annotation.EnableAsync;
16 | import org.springframework.transaction.annotation.EnableTransactionManagement;
17 |
18 | import javax.sql.DataSource;
19 | import java.util.HashMap;
20 | import java.util.Map;
21 |
22 | @SpringBootApplication
23 | @EnableAsync
24 | @EnableTransactionManagement
25 | @ConfigurationPropertiesScan
26 | public class SampleSpringApplication {
27 |
28 | public static void main(String[] args) {
29 | SpringApplication.run(SampleSpringApplication.class, args);
30 | }
31 |
32 | @Bean
33 | public DataSource dataSource(ApplicationConfiguration applicationConfiguration) {
34 |
35 | AbstractRoutingDataSource dataSource = new TenantAwareRoutingSource();
36 |
37 | Map