Multi-Tenant Query Boundary
Evidence status: tenant isolation has been exercised by the polyglot Order Search acceptance. Portable automatic policy coverage across every direct-ID, relation, aggregate, mutation, background-job, and federation path remains a runtime conformance requirement rather than a blanket released claim.
Problem
Return only data belonging to the authenticated tenant, without trusting a tenant identifier supplied by the browser, request JSON, or coding agent.
Applicability
Use this recipe when tenant ownership is part of the model and application
identity is resolved into UserContext.
This recipe does not define the correct tenant/root model for every system. Review model ownership and tenancy rules before implementing the query.
Boundary
authenticated request
-> trusted server adapter resolves authoritative tenant
-> application initializes UserContext with tenant, actor and policy
-> runtime RequestPolicy applies the tenant constraint at execution
-> business and untrusted filters can only narrow the effective request
-> comment + purpose
-> execute
The client may request sorting, search terms, or pagination. It must not decide the authoritative tenant boundary.
The security invariant is enforced below ordinary business query construction. An explicit tenant filter in a controller, service, or generated request may be useful business narrowing, but it is not the tenant security boundary: one forgotten filter must not expose another tenant.
Java Shape
public SmartList<Order> listOrders(
CustomUserContext ctx, String userFilters) {
return Q.orders()
.findWithJsonExpr(userFilters)
.comment("Query tenant orders")
.purpose("Render the current tenant order list")
.executeForList(ctx);
}
Install a mandatory policy while assembling the trusted context. Exact runtime registration APIs are version-specific; this is the intended responsibility:
public final class TenantRequestPolicy implements RequestPolicy {
@Override
public void enforceSelect(UserContext ctx, SearchRequest<?> request) {
appendTenantConstraint(request, ((CustomUserContext) ctx).getMerchant());
}
}
The same policy family must cover mutations and every protected execution
shape. Do not accept merchantId as a replacement for trusted context
resolution.
Rust Shape
let orders = Q::orders()
.comment("Query tenant orders")
.purpose("Render the current tenant order list")
.execute_for_list(&ctx)
.await?;
The Rust RequestPolicy reads the authoritative tenant from UserContext and
combines its predicate with the request before provider execution. A missing
tenant or policy registration must fail closed. Ordinary TFP/federation and
dynamic JSON payloads cannot replace that local context state.
Verification
Create an integration test with at least two tenants:
- Insert one visible record for tenant A.
- Insert one record with otherwise identical fields for tenant B.
- Query using tenant A's context.
- Confirm only tenant A's record is returned.
- Submit a user filter containing tenant B's identifier and confirm it cannot broaden the trusted scope.
- Repeat for direct ID lookup and association loading.
Failure Modes
| Symptom | Risk | Fix |
|---|---|---|
| Tenant ID comes from request JSON | Cross-tenant data access. | Resolve tenant from authenticated context and apply it as a trusted server constraint. |
| List query is scoped but ID lookup is not | Boundary bypass. | Enforce one context-installed runtime policy and test every entry path. |
| Child relation loads escape tenant scope | Cross-tenant association exposure. | Review generated relationships and nested request policy. |
| Background task has no identity | Unowned read/write and weak audit trail. | Create an explicit service identity and tenant scope in UserContext. |