SteelreedSteelreed
GitHub
QueryFence0.1.0 is in preparation

Catch tenant leaks in your SQL before production does.

QueryFence checks every SQL statement your integration tests send to the database, and fails the build on the one that forgets tenant_id. Open source, for the JVM.

~/shop · ./mvnw test

Works with the stack you already run

The problem

Why tenant leaks slip through

01

Code review misses it

The missing filter is an absence, and absences are hard to see in a diff.

- WHERE o.tenant_id = ? AND o.status = ?
+ WHERE o.status = ?
02

Tests stay green

Fixtures usually hold one tenant, so "all orders" and "this tenant's orders" return the same rows.

✓ OrderServiceTest   62 passed
BUILD SUCCESS
03

Production leaks

Customer A opens a page and sees customer B's orders.

tenant  order     total
acme    #10231    1,280
globex  #88412      940
Simulation

Watch a leak get caught

Your application OrderService OrderRepository JUnit 5 · test run QueryFence Database ✕ missing tenant_id
Filtered by tenantMissing tenant filterSimulation
  1. 01Tests run as usual
  2. 02Every SQL statement is captured
  3. 03Checked against your policy
  4. 04The leak fails the build
How it works

Three steps, no test code to change

pom.xml
<dependency>
  <groupId>com.steelreed</groupId>
  <artifactId>queryfence-spring-test</artifactId>
  <version>0.1.0</version>
  <scope>test</scope>
</dependency>
Rules

What it catches

MISSING_PREDICATE

A query with no tenant filter

SELECT * FROM invoice
WHERE number = ?
MISSING_PREDICATE

A filter that an OR cancels out

WHERE o.tenant_id = ?
   OR o.status = 'OPEN'
MISSING_PREDICATE

A join that leaves one table open

FROM purchase_order o
JOIN order_item i ON i.order_id = o.id
WHERE o.tenant_id = ?
PRIMARY_KEY_LOOKUP

A lookup by id alone

SELECT * FROM customer
WHERE id = ?   -- findById
NO_WHERE

An UPDATE or DELETE with no WHERE

UPDATE customer
SET status = 'ARCHIVED'
MISSING_INSERT_COLUMN

An INSERT that skips the tenant

INSERT INTO audit_log (action, at)
VALUES (?, ?)

SQL it cannot parse is reported too, never trusted. All rules →

Compatibility

Wherever your SQL comes from

Data access it sees

  • JPA / Hibernatederived queries, JPQL, Pageable count queries
  • MyBatisthe statement each set of dynamic arguments produced
  • jOOQthe SQL it renders
  • JdbcTemplateand plain JDBC
  • Native SQLanything behind a DataSource

Tested on every pull request

  • MySQL 8.4Testcontainers
  • PostgreSQL 17Testcontainers
  • JUnit5.10 → 6.1
  • Spring Boot3.3 → 4.1
  • Java17 · 21 · 25
Compared

Enforce at runtime, verify in tests

QueryFence does not replace these tools. It checks that they are doing their job.

What it doesWhere it worksWhat it misses
Hibernate @TenantIdAdds the tenant condition to entity queries at runtimeHibernate entity queriesNative SQL, JdbcTemplate, MyBatis, jOOQ, a disabled filter
MyBatis-Plus tenant interceptorRewrites SQL at runtimeMyBatis-PlusOther data-access code, tables on its ignore list
Postgres RLSThe database refuses other tenants' rowsPostgresOther databases, roles that bypass RLS, a wrong session tenant
QueryFenceChecks every executed statement against a policy and fails the buildAny JDBC DataSourceRuntime protection, SQL your tests never run
Status

Measured, not promised

Run against a realistic multi-tenant app it was not written for

75SQL statements checked
15real leaks found
205golden test cases
4false positives, published
Read the dogfood report →
QueryFenceApache-2.0 · Java 17+
0.1.0 in preparation

In 0.1

  • Tenant filter, UPDATE and DELETE rules
  • JUnit 5 extension and Spring Boot test support
  • FAIL and REPORT modes
  • Suppressions that require a reason
  • Console summary and report.json

Not yet

  • Blocking SQL at runtime
  • Checking parameter values
  • Baseline file, HTML report
FAQ

Questions

No. It watches statements through a DataSource proxy and passes them to the driver unchanged.

No. The dependency is test scope, there is no annotation to add, and suppressions live in the policy file.

A primary key is not a tenant filter: ids can be guessed, and where id = ? happily returns another tenant's row. Use findByIdAndTenantId, or map the tenant with Hibernate @TenantId.

Each distinct SQL string is parsed once and cached. The integration suite checks tens of statements per test with no measurable difference.

Not in 0.1. The engine has no JDBC or test dependencies, which keeps a runtime mode possible later, but nothing ships for it yet.

Start in REPORT mode, read the report, fix or suppress with a reason, then switch to FAIL. The adoption guide walks through it in about an hour.