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 = ?
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.
$ ./mvnw test
✓ OrderServiceTest#createsOrder · 3 statements
✓ OrderServiceTest#paysInvoice · 5 statements
✓ CustomerServiceTest#archives · 2 statements
✓ OrderServiceTest#listsPendingOrders · 1 statement
QueryFence: 1 violation (mode FAIL)
[tenant-isolation] require-predicate
table : purchase_order (alias o)
problem : no predicate on o.tenant_id
sql : SELECT o.id, o.total FROM purchase_order o WHERE o.status = ?
origin : OrderRepository#findByStatus (OrderRepository.java:42)
BUILD FAILURE · the leak never reaches production
Works with the stack you already run
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 = ?
Fixtures usually hold one tenant, so "all orders" and "this tenant's orders" return the same rows.
✓ OrderServiceTest 62 passed BUILD SUCCESS
Customer A opens a page and sees customer B's orders.
tenant order total acme #10231 1,280 globex #88412 940
<dependency> <groupId>com.steelreed</groupId> <artifactId>queryfence-spring-test</artifactId> <version>0.1.0</version> <scope>test</scope> </dependency>
version: 1 mode: FAIL # or REPORT while you adopt it onUnparseable: FAIL rules: - id: tenant-isolation type: require-predicate column: tenant_id tables: [purchase_order, order_item, invoice] - id: no-unbounded-update type: update-without-where
$ ./mvnw test QueryFence: 1 violation (mode FAIL) [tenant-isolation] MISSING_PREDICATE table : purchase_order (alias o) sql : SELECT o.id, o.total FROM purchase_order o WHERE o.status = ? origin : OrderRepository#findByStatus (OrderRepository.java:42) Full report: target/queryfence/report.json
SELECT * FROM invoice WHERE number = ?
WHERE o.tenant_id = ?
OR o.status = 'OPEN'FROM purchase_order o JOIN order_item i ON i.order_id = o.id WHERE o.tenant_id = ?
SELECT * FROM customer
WHERE id = ? -- findByIdUPDATE customer SET status = 'ARCHIVED'
INSERT INTO audit_log (action, at) VALUES (?, ?)
SQL it cannot parse is reported too, never trusted. All rules →
QueryFence does not replace these tools. It checks that they are doing their job.
| What it does | Where it works | What it misses | |
|---|---|---|---|
Hibernate @TenantId | Adds the tenant condition to entity queries at runtime | Hibernate entity queries | Native SQL, JdbcTemplate, MyBatis, jOOQ, a disabled filter |
| MyBatis-Plus tenant interceptor | Rewrites SQL at runtime | MyBatis-Plus | Other data-access code, tables on its ignore list |
| Postgres RLS | The database refuses other tenants' rows | Postgres | Other databases, roles that bypass RLS, a wrong session tenant |
| QueryFence | Checks every executed statement against a policy and fails the build | Any JDBC DataSource | Runtime protection, SQL your tests never run |
Run against a realistic multi-tenant app it was not written for
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.