Single-schema multi-tenancy with a Hibernate filter is a popular pattern. Every tenant table has a tenant_id column, a @Filter adds tenant_id = :tenantId to every query, and an aspect turns the filter on for each request. Lists, searches and reports are all isolated, and the tests for them pass.
There is one hole in it that is easy to miss, and my own starter kit had it. This is how it works and how version 5.3 closes it in two layers.
The gap described here is fixed in Arab Enterprise Kit 5.3, and the update is free for every v5.x license.
The hole
Spring Data's SimpleJpaRepository.findById calls EntityManager.find. Hibernate filters apply to queries (HQL, JPQL, Criteria and filtered collections). They do not apply to loading an entity by its primary key.
// Looks safe, is not: EntityManager.find ignores the tenant filter
Invoice invoice = invoiceRepository.findById(42L).orElseThrow();
So GET /api/invoices/42 sent by a user of company A can load company B's invoice, as long as A knows or guesses the id. Every list endpoint still looks perfectly isolated, because lists go through queries. A test suite that only checks listings will never see it.
deleteById goes through findById, so it has the same gap.
Layer 1: make findById a query
The fix at the repository level is to run the lookup as a JPQL query, so the filter applies. The kit does it once, in a repository base class:
public class TenantAwareRepository<T, ID> extends SimpleJpaRepository<T, ID> {
private final EntityManager entityManager;
private final JpaEntityInformation<T, ?> entityInformation;
public TenantAwareRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
super(entityInformation, entityManager);
this.entityManager = entityManager;
this.entityInformation = entityInformation;
}
@Override
public Optional<T> findById(ID id) {
if (id == null) {
throw new IllegalArgumentException("The given id must not be null");
}
String idName = entityInformation.getRequiredIdAttribute().getName();
String jpql = "select e from " + entityInformation.getEntityName() + " e where e." + idName + " = :id";
return entityManager.createQuery(jpql, getDomainClass())
.setParameter("id", id)
.getResultStream()
.findFirst();
}
}
and registers it for every repository:
@EnableJpaRepositories(repositoryBaseClass = TenantAwareRepository.class)
Because deleteById calls findById, deletes are covered too. Entities that have no tenant filter (subscription plans, for example) behave exactly as before.
Layer 2: an explicit check in the services
A repository base class is easy to bypass later: someone adds a native query or calls the EntityManager directly. So the services that load tenant data by id also check ownership:
public final class TenantGuard {
private TenantGuard() {}
public static boolean ownedByCurrentTenant(BaseEntity entity) {
return ownedByCurrentTenant(entity.getTenantId());
}
public static boolean ownedByCurrentTenant(String entityTenantId) {
String tenantId = TenantContext.getTenantId();
return tenantId != null && tenantId.equals(entityTenantId);
}
}
Invoice existing = invoiceRepository.findById(id)
.filter(TenantGuard::ownedByCurrentTenant)
.orElseThrow(() -> new IllegalArgumentException("Invoice not found: " + id));
Note the error: another tenant's invoice looks exactly like a missing one. Answering "forbidden" would tell the caller that id 42 exists.
Fail closed when there is no tenant
The aspect that enables the filter has one more decision to make: what happens when no tenant is set at all? A scheduled job or a background thread that forgets to set it should not see every tenant's data.
if (tenantId == null) {
if (isTenantAgnostic(joinPoint)) return;
tenantId = NO_TENANT; // "__no_tenant__" matches no row
}
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
The few services that really run without a tenant (sign-in, registration, payment webhooks that read the tenant from the payload) opt out with a @TenantAgnostic annotation. Everything else sees nothing instead of everything.
The tests that hold it in place
The kit's isolation tests seed two tenants and check each path, not just the listing:
- listing as tenant A does not return tenant B's items
- reading tenant B's item by id as tenant A is rejected
- updating it by id is rejected and leaves it unchanged
- deleting it by id is rejected and keeps the item
- a background thread with tenant B's context sees only tenant B's items
- a background thread with no tenant context sees no tenant data
If your isolation tests only cover the first line, they would not have caught this.
A checklist for your own code
- Search for
findById,getReferenceById,deleteByIdand any directEntityManager.findorgetReferenceon tenant entities. - Decide where the guarantee lives: a repository base class, a service check, or both.
- Make "no tenant" mean "no data".
- Write the by-id tests, not only the list tests.
Talk to me
This is the first post in a series about building Arab Enterprise Kit, an Arabic-first, multi-tenant SaaS foundation in Java 25, Spring Boot 4 and Angular 21. There's a free Lite edition on GitHub, and the full kit is on Gumroad. I build SaaS products and take freelance full-stack and backend projects: mahmoud-farouk-portfolio.vercel.app.
How does your multi-tenant app handle lookups by id?
Top comments (0)