tenantlayer.io

Examples

Two 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.

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.3.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.

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.