Defense-in-Depth: Database-Level Tenant Enforcement and Hibernate Filters
Defense-in-Depth: Database-Level Tenant Enforcement
The Assumption
If the SecurityFilterChain enforces tenant isolation, the database layer does not need its own enforcement. The assumption: application-level authorization is sufficient.
Every IDOR vulnerability ever discovered proves this wrong. A developer adds a new endpoint, forgets the tenant check, and suddenly cross-tenant data is accessible via a direct object reference. Token-level enforcement catches the request at the gate. Database-level enforcement catches it at the data.
The gap between “every endpoint checks tenant” and “the database physically cannot return cross-tenant data” is the gap that IDOR vulnerabilities exploit. Closing it requires enforcement at the query level, where a missing WHERE clause is impossible because the ORM injects it automatically.
The Attack
IDOR via missing tenant filter. A developer adds a new reporting endpoint:
// VULNERABLE: No tenant filter
@GetMapping("/api/reports/{reportId}")
public Report getReport(@PathVariable UUID reportId) {
return reportRepository.findById(reportId)
.orElseThrow(NotFoundException::new);
// reportId is a UUID. If an attacker guesses or enumerates UUIDs,
// they can access reports from any tenant.
}
The findById method queries the database without a tenant filter. The tenant enforcement filter at the SecurityFilterChain level checked that the request path matches the token’s tenant, but this endpoint uses a report ID directly (not a tenant-scoped path like /api/tenants/{tenant}/reports/{id}). The tenant filter at the HTTP layer has no path parameter to compare.
The fix at the application level: add a tenant check to every repository query. The problem: developers forget. New endpoints, refactors, copy-paste from single-tenant examples. Each omission is a cross-tenant data leak.
The defense-in-depth fix: make the database layer enforce tenant isolation automatically.
The Spec or Mechanism
Three mechanisms, in order of increasing strength:
-
Hibernate @Filter: ORM-level WHERE clause injection. Every query for a filtered entity automatically includes
AND tenant_id = :tenantId. Cannot be bypassed by application code unless the filter is explicitly disabled. -
Spring Data JPA SpEL:
@Queryannotations with SpEL expressions that inject the current tenant. Type-safe, but only applies to explicitly annotated queries. -
PostgreSQL Row-Level Security: Database-level policy enforcement. Even direct SQL queries (via SQL injection or admin access) cannot return cross-tenant rows. Enforcement is at the database engine level, below the ORM.
The Implementation
Hibernate @Filter
@Entity
@Table(name = "projects")
@FilterDef(name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
@Column(nullable = false)
private String name;
private String description;
@Column(name = "created_at")
private Instant createdAt;
}
// Automatically enable the tenant filter for every request
@Component
public class TenantFilterAspect {
@PersistenceContext
private EntityManager entityManager;
@Around("execution(* com.saas.platform.repository.*.*(..))")
public Object applyTenantFilter(ProceedingJoinPoint joinPoint) throws Throwable {
Session session = entityManager.unwrap(Session.class);
String tenantId = TenantContext.getCurrent();
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
try {
return joinPoint.proceed();
} finally {
session.disableFilter("tenantFilter");
}
}
}
With this aspect, every repository method automatically includes the tenant filter. The vulnerable endpoint from earlier now works correctly without modification:
// Previously VULNERABLE, now safe with Hibernate filter active
@GetMapping("/api/reports/{reportId}")
public Report getReport(@PathVariable UUID reportId) {
return reportRepository.findById(reportId)
.orElseThrow(NotFoundException::new);
// Hibernate adds: WHERE id = ? AND tenant_id = 'acme-corp'
// Reports from other tenants are invisible.
}
Spring Data JPA with SpEL
public interface ProjectRepository extends JpaRepository<Project, UUID> {
// Standard query methods work with the Hibernate filter.
// For custom queries, use SpEL to inject tenant context:
@Query("SELECT p FROM Project p WHERE p.tenantId = :#{@tenantContext.current} " +
"AND p.name LIKE %:search%")
List<Project> searchByName(@Param("search") String search);
@Query("SELECT COUNT(p) FROM Project p WHERE p.tenantId = :#{@tenantContext.current}")
long countForCurrentTenant();
}
// Spring bean for SpEL access
@Component("tenantContext")
public class TenantContextBean {
public String getCurrent() {
return TenantContext.getCurrent();
}
}
Insert Protection
Hibernate filters protect reads, but writes need separate enforcement. A developer could create a record with the wrong tenant_id:
// VULNERABLE: No tenant enforcement on insert
@PostMapping("/api/projects")
public Project createProject(@RequestBody ProjectRequest request) {
Project project = new Project();
project.setName(request.name());
project.setTenantId(request.tenantId()); // Client-provided! Could be any tenant.
return projectRepository.save(project);
}
// HARDENED: Tenant enforced on insert via entity listener
@Entity
@Table(name = "projects")
@EntityListeners(TenantEntityListener.class)
public class Project {
// ...
}
@Component
public class TenantEntityListener {
@PrePersist
public void setTenant(Object entity) {
if (entity instanceof TenantScoped tenantScoped) {
String currentTenant = TenantContext.getCurrent();
if (tenantScoped.getTenantId() == null) {
tenantScoped.setTenantId(currentTenant);
} else if (!tenantScoped.getTenantId().equals(currentTenant)) {
throw new SecurityException(
"Attempted to create entity for tenant " +
tenantScoped.getTenantId() +
" but authenticated as tenant " + currentTenant);
}
}
}
@PreUpdate
public void validateTenant(Object entity) {
if (entity instanceof TenantScoped tenantScoped) {
String currentTenant = TenantContext.getCurrent();
if (!tenantScoped.getTenantId().equals(currentTenant)) {
throw new SecurityException(
"Attempted to modify entity belonging to tenant " +
tenantScoped.getTenantId());
}
}
}
}
public interface TenantScoped {
String getTenantId();
void setTenantId(String tenantId);
}
PostgreSQL Row-Level Security
The strongest enforcement layer. Even if the ORM is bypassed (SQL injection, direct database access), RLS policies prevent cross-tenant access.
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see rows matching their session tenant
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant'));
-- Force RLS even for table owners (prevents bypassing by admin roles)
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
// Set the tenant context for PostgreSQL RLS at connection checkout
@Component
public class TenantConnectionCustomizer implements ConnectionCustomizer {
@Override
public void customize(Connection connection) throws SQLException {
String tenantId = TenantContext.getCurrent();
try (PreparedStatement stmt = connection.prepareStatement(
"SET app.current_tenant = ?")) {
stmt.setString(1, tenantId);
stmt.execute();
}
}
}
// HikariCP integration
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db.saas.example:5432/saas");
config.setUsername("app_user");
config.setPassword(System.getenv("DB_PASSWORD"));
// Set tenant context on every connection checkout
config.setConnectionInitSql("SET app.current_tenant = ''");
return new HikariDataSource(config);
}
// Use an aspect to set the tenant before each transaction
@Aspect
@Component
public class RlsTenantAspect {
@PersistenceContext
private EntityManager em;
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void setRlsTenant() {
String tenantId = TenantContext.getCurrent();
em.createNativeQuery("SET app.current_tenant = :tenant")
.setParameter("tenant", tenantId)
.executeUpdate();
}
}
The Verification
@SpringBootTest
@Transactional
class DatabaseTenantIsolationTest {
@Autowired
private ProjectRepository projectRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void setupTestData() {
// Create projects for two tenants
TenantContext.setCurrent("acme-corp");
enableTenantFilter("acme-corp");
Project acmeProject = new Project();
acmeProject.setName("Acme Project");
acmeProject.setTenantId("acme-corp");
entityManager.persist(acmeProject);
// Temporarily disable filter to insert cross-tenant data
entityManager.unwrap(Session.class).disableFilter("tenantFilter");
Project globexProject = new Project();
globexProject.setName("Globex Project");
globexProject.setTenantId("globex-inc");
entityManager.persist(globexProject);
entityManager.flush();
}
@Test
void hibernateFilterPreventsAccessToOtherTenantData() {
TenantContext.setCurrent("acme-corp");
enableTenantFilter("acme-corp");
List<Project> projects = projectRepository.findAll();
assertThat(projects).hasSize(1);
assertThat(projects.get(0).getName()).isEqualTo("Acme Project");
assertThat(projects.get(0).getTenantId()).isEqualTo("acme-corp");
// Globex project is invisible
}
@Test
void findByIdReturnsEmptyForOtherTenantEntity() {
TenantContext.setCurrent("acme-corp");
enableTenantFilter("acme-corp");
// Get the Globex project's ID (we know it exists)
entityManager.unwrap(Session.class).disableFilter("tenantFilter");
Project globexProject = entityManager.createQuery(
"SELECT p FROM Project p WHERE p.tenantId = 'globex-inc'", Project.class)
.getSingleResult();
UUID globexId = globexProject.getId();
enableTenantFilter("acme-corp");
// Try to access it as acme-corp
Optional<Project> result = projectRepository.findById(globexId);
assertThat(result).isEmpty(); // Filtered out by Hibernate filter
}
@Test
void insertWithWrongTenantIsRejected() {
TenantContext.setCurrent("acme-corp");
Project wrongTenant = new Project();
wrongTenant.setName("Sneaky Project");
wrongTenant.setTenantId("globex-inc"); // Wrong tenant!
assertThatThrownBy(() -> projectRepository.save(wrongTenant))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("Attempted to create entity for tenant globex-inc");
}
@Test
void updateCannotChangeTenantId() {
TenantContext.setCurrent("acme-corp");
enableTenantFilter("acme-corp");
Project project = projectRepository.findAll().get(0);
project.setTenantId("globex-inc"); // Attempt to move to another tenant
assertThatThrownBy(() -> {
projectRepository.save(project);
entityManager.flush();
}).isInstanceOf(SecurityException.class);
}
private void enableTenantFilter(String tenantId) {
entityManager.unwrap(Session.class)
.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
}
}
The second test is the IDOR prevention test: given the exact UUID of another tenant’s entity, findById returns empty instead of the entity. Without the Hibernate filter, this test would return the Globex project because findById queries by primary key only. With the filter, the query becomes WHERE id = ? AND tenant_id = 'acme-corp', and the Globex project (tenant_id = ‘globex-inc’) is excluded.
The third test validates write protection: the application cannot create entities attributed to a different tenant. Combined with the read protection from the Hibernate filter, this creates bidirectional tenant isolation at the data layer.