tenantlayer.io

Examples

Three complete walkthroughs. The first starts from an empty application; the second starts from one that already has customers and cannot have a flag day. Both end in the same place — application code with no tenancy logic in it. The third is what you need once it is actually running: creating tenants, operating them, and finding out that isolation has stopped working before a customer does.

A new application

Roughly twenty minutes, and most of it is SQL you would have written anyway.

  1. 1

    Add the dependency

    <dependency>
      <groupId>io.tenantlayer</groupId>
      <artifactId>tenantlayer-spring-boot-starter</artifactId>
      <version>0.4.0</version>
    </dependency>

    Java 17+, Spring Boot 3.3+, Postgres. Hibernate 6 and 7 both work.

  2. 2

    Create the table, the policy and the role

    The tenant column is filled in by the database, so application code cannot set it wrongly or forget it. The role matters as much as the policy: a superuser or the table owner bypasses row-level security entirely.

    create table orders (
        id           bigserial primary key,
        -- Filled in by the database from the connection's tenant. Your code never sets it.
        tenant_id    varchar(64)  not null default current_setting('tenantlayer.tenant', true),
        customer     varchar(255) not null,
        amount_cents bigint       not null
    );
    
    create index idx_orders_tenant on orders (tenant_id);
    
    alter table orders enable row level security;
    alter table orders force row level security;
    
    create policy tenant_isolation on orders
        using (tenant_id = nullif(current_setting('tenantlayer.tenant', true), ''));
    
    -- The role your application connects as. Neither superuser nor table owner,
    -- or the policy is never applied to it.
    create role orders_app login password :'app_password';
    grant usage on schema public to orders_app;
    grant select, insert, update, delete on orders to orders_app;
    grant usage, select on all sequences in schema public to orders_app;
  3. 3

    Tell it where the tenant comes from

    tenantlayer.resolvers=JWT
    tenantlayer.jwt-claim=tenant_id
    tenantlayer.strict=true
    
    spring.security.oauth2.resourceserver.jwt.issuer-uri=https://your-idp.example.com/
    
    spring.datasource.url=jdbc:postgresql://localhost:5432/app
    spring.datasource.username=orders_app
    spring.datasource.password=${DB_PASSWORD}

    A JWT claim rather than a header, because a header is whatever the caller typed. See tenant resolution for the other sources and how a chain orders them.

  4. 4

    Write the entity with no tenant in it

    @Entity
    @Table(name = "orders")
    public class Order {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String customer;
    
        @Column(name = "amount_cents")
        private long amountCents;
    
        // Written by the database, read back after insert. Never set by this class.
        @Generated(event = EventType.INSERT)
        @Column(name = "tenant_id", insertable = false, updatable = false)
        private String tenantId;
    }
  5. 5

    Write the repository and controller with no tenant in them either

    public interface OrderRepository extends JpaRepository<Order, Long> { }
    
    @RestController
    @RequestMapping("/orders")
    class OrderController {
    
        private final OrderRepository orders;
    
        OrderController(OrderRepository orders) {
            this.orders = orders;
        }
    
        @GetMapping
        List<Order> list() {
            return orders.findAll();          // the acting tenant's rows, and no others
        }
    
        @PostMapping
        @ResponseStatus(HttpStatus.CREATED)
        Order place(@RequestBody Order order) {
            return orders.save(order);        // stamped with the acting tenant by the database
        }
    }

    findAll() really does mean all — Postgres returns only the rows this connection is allowed to see. The same is true of a native query, a JdbcTemplate call or a bulk update.

  6. 6

    Prove it, then break it

    @Test
    @WithTenant("acme")
    void acmeSeesOnlyItsOwnOrders() {
        assertThat(orders.findAll()).isNotEmpty();   // acme can see its own
        assertTenantCannotSee("globex");             // and nothing of globex's
    }

    Both halves matter: “cannot see the other tenant” passes trivially against an empty table, so assertTenantCannotSee refuses to run unless globex genuinely has rows. Then drop the policy and confirm the test goes red — see testing.

An existing application

You already have customers, a tenant column of some kind, and predicates scattered through your repositories. The order below is deliberate: nothing changes behaviour until step 4, and each step before it is independently revertible.

Where you probably are

// Every method carries the tenant, and every one of them is a chance to forget.
public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByTenantId(String tenantId);

    Optional<Order> findByIdAndTenantId(Long id, String tenantId);
}

@GetMapping("/orders")
List<Order> list(@RequestHeader("X-Tenant-ID") String tenantId) {
    return orders.findByTenantId(tenantId);
}

This works until someone writes one query without the predicate. That is the bug class being removed — not a performance problem, and not a tidiness problem.

  1. 1

    Add the dependency and leave it switched off

    tenantlayer.resolvers=HEADER
    tenantlayer.strict=false     # temporarily: nothing is rejected yet

    The filter resolves and binds a tenant, and nothing yet depends on it. Deploy this on its own and confirm nothing changed.

  2. 2

    Backfill the tenant column

    -- 1. The column, if you do not already have one. Backfill before adding the constraint.
    alter table orders add column tenant_id varchar(64);
    
    update orders set tenant_id = accounts.slug
      from accounts where orders.account_id = accounts.id;
    
    alter table orders alter column tenant_id set not null;
    alter table orders alter column tenant_id
        set default current_setting('tenantlayer.tenant', true);
    
    create index concurrently idx_orders_tenant on orders (tenant_id);

    concurrently because the table has rows and traffic. The default is added after the backfill so existing writes are unaffected while it runs.

  3. 3

    Add the policy — one table at a time

    -- 2. The policy, one table at a time. Nothing changes until the role does.
    alter table orders enable row level security;
    alter table orders force row level security;
    
    create policy tenant_isolation on orders
        using (tenant_id = nullif(current_setting('tenantlayer.tenant', true), ''));
    
    -- 3. The switch: move the application to a role the policy applies to.
    --    Until this line, every query still behaves exactly as it did before.
    create role orders_app login password :'app_password';
    grant select, insert, update, delete on orders to orders_app;

    Adding a policy changes nothing while your application still connects as the owner. That is what makes this safe to do table by table, over as many deploys as you like.

  4. 4

    Switch the role — the only step that changes behaviour

    spring.datasource.username=orders_app
    spring.datasource.password=${APP_DB_PASSWORD}
    tenantlayer.strict=true

    Now the policy applies. Everything still works, because your queries still carry their own predicates — they are simply redundant. Revert by pointing the datasource back at the old role.

  5. 5

    Delete the predicates, once they are redundant

    // The predicates come out only once the policy is enforcing them.
    public interface OrderRepository extends JpaRepository<Order, Long> { }
    
    @GetMapping("/orders")
    List<Order> list() {
        return orders.findAll();
    }

    This is the payoff, and it comes last on purpose. Removing a predicate before the policy is enforcing it turns a redundant filter into no filter at all.

  6. 6

    Verify that the policy — not the query — is doing the work

    @Test
    @WithTenant("acme")
    void thePolicyIsDoingTheWorkNowAndNotTheQuery() {
        // findAll() has no predicate at all. If the policy is not applied,
        // this returns every tenant's rows and the assertion fails loudly.
        assertThat(orders.findAll()).isNotEmpty();
        assertTenantCannotSee("globex");
    }

    Run this after step 5, when there is no predicate left to pass the test for the wrong reason. If it goes green with the policy dropped, the isolation was never coming from the database.

Running it in production

Isolation working on your laptop is the start. These four are what the first production month asks for, and each one is a thing people previously hand-rolled and got subtly wrong.

  1. 1

    Create a tenant from your own signup path

    One call does the whole sequence: registry row, that tenant’s migrations, your hooks, then ACTIVE. Nothing is servable until all of it succeeded.

    @PostMapping("/signup")
    Account signUp(@RequestBody SignUpRequest request) {
        Account account = accounts.create(request);
        provisioning.onboard(account.slug());
        return account;
    }

    The order is the point. Seed data written before a tenant is bound fails the policy under row-level security, and has no connection at all under database-per-tenant — which is why hooks run inside the new tenant’s context and use ordinary repositories:

    @Component
    class SeedStarterData implements TenantProvisioningHook {
    
        private final SettingsRepository settings;
        private final CategoryRepository categories;
    
        @Override
        public void onTenantCreated(String tenantId) {
            // No tenant argument. The new tenant is already bound while this runs.
            settings.save(Settings.defaults());
            categories.saveAll(Category.starterSet());
        }
    
        @Override
        public int order() {
            return 10;   // lower runs first
        }
    }

    A hook that throws stops provisioning and leaves the tenant PROVISIONING — not absent, which looks like a tenant nobody asked for, and not ACTIVE, which looks ready and is not. Onboarding is idempotent, so retrying is calling it again; a hook must be safe to run twice, because it will be.

  2. 2

    Operate the registry without a deploy

    Off unless you both enable and expose it, and it inherits whatever already guards your management endpoints.

    management.endpoint.tenants.enabled=true
    management.endpoints.web.exposure.include=tenants
    
    # worth doing: keep management off the public port entirely
    management.server.port=9001
    management.server.address=127.0.0.1
    curl localhost:9001/actuator/tenants            # every tenant and its status
    curl -X POST localhost:9001/actuator/tenants \
         -H 'Content-Type: application/json' \
         -d '{"tenantId":"globex","region":"eu-west-1"}'

    Creating goes through provisioning, so an operator cannot produce a tenant that skipped its migrations, and PROVISIONING cannot be set by hand. Delete removes the registry row and nothing else — a tenant’s data outliving its row is a retention decision, not something an HTTP verb should make for you.

  3. 3

    Find out the policy is missing before a customer does

    A policy nobody applied, or one a later migration dropped, looks exactly like a policy that works. This reads pg_class and pg_policies on the connection the application actually uses, and says so on the way up:

    tenantlayer.check.enabled=true
    WARN  isolation check: 2 of 3 findings mean isolation is NOT being enforced
    WARN    NOT_ENFORCED  invoices — is tenant-scoped but row-level security is not enabled
              fix: alter table invoices enable row level security;
    WARN    FRAGILE       orders — does not have FORCE ROW LEVEL SECURITY. It is enforced
                          today only because the connecting role does not own the table
              fix: alter table orders force row level security;

    Warn-only by default: it tells you, it does not refuse to start. The FRAGILE case is the one worth staring at — that table is protected today by an accident of which role you connect as, and a change to the connection user silently unprotects it.

  4. 4

    Get per-tenant latency without an unbounded metrics bill

    A tenant tag on every observation. The trap is cardinality: one tag value per tenant multiplies every timer by your tenant count, and a tenant id that arrives in a header is attacker-controlled.

    tenantlayer.metrics.enabled=true
    tenantlayer.metrics.max-tenants=100
    tenantlayer.metrics.overflow-value=__other__
    http_server_requests_seconds_count{uri="/orders", tenant="acme"}     1423
    http_server_requests_seconds_count{uri="/orders", tenant="globex"}    118
    http_server_requests_seconds_count{uri="/orders", tenant="__none__"}    2

    The first hundred tenants keep their identity and the rest fold into __other__, so growth cannot run the bill up. __none__ is kept distinct from overflow on purpose — requests arriving with no tenant at all is a number you want to watch, not one to lose in an aggregate.

    # slowest tenants right now
    topk(10, sum by (tenant) (rate(http_server_requests_seconds_count[5m])))
    
    # requests reaching you with no tenant resolved
    rate(http_server_requests_seconds_count{tenant="__none__"}[5m])

Then what?

Recipes covers what comes next — onboarding a tenant, suspending one so it actually stops being readable, nightly jobs across every tenant, admin endpoints that span tenants, Kafka, and migrations. Each says what breaks when it is done wrong, because most of these fail silently rather than loudly.

And examples/order-service is all of the above as a running application, with thirty-two tests that each assert something which can fail.