Multi-Tenant Query Boundary
Evidence status: trusted context composition and governance-key rejection exist across all seven runtimes. Automatic Request Policy hook depth differs by language, so portable isolation still requires application tests for every direct-ID, relation, Facet, aggregate, mutation, background-job, and federation path.
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 policy hook and/or trusted provider adapter applies tenant scope
-> 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 must be 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.
Current Hook Coverage
| Runtime | Request Policy coverage | Required qualification |
|---|---|---|
| Java | Select, insert, update, delete, recover | Test every operation plus nested and analytic reads. |
| Rust | Select, insert, update, delete, recover; entity behavior hooks | Test policy and behavior composition. |
| Go | Select, insert, update, delete, recover | Test every operation and readiness/provider reachability. |
| Swift | Query | Add mutation guards at the trusted application/provider/TFP boundary. |
| Python | Query | Add mutation guards at the trusted application/provider/TFP boundary. |
| .NET | Query | Add mutation guards at the trusted application/provider/TFP boundary. |
| TypeScript | Composition stores requestPolicy; core prepareQuery() does not automatically apply it | Enforce Query and Mutation scope in the server adapter/custom data service and retain negative tests. |
Do not infer missing application protection from a narrower general hook, and do not claim automatic protection where the runtime does not currently wire it. The composition root remains responsible for complete coverage.
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.
- Repeat for Facets, grouped/relation aggregates, create, update, delete, recover, background jobs, and every enabled TFP endpoint.
- Remove tenant, policy, provider, or schema wiring in turn and confirm readiness fails.
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. |