Skip to main content

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:

  1. Insert one visible record for tenant A.
  2. Insert one record with otherwise identical fields for tenant B.
  3. Query using tenant A's context.
  4. Confirm only tenant A's record is returned.
  5. Submit a user filter containing tenant B's identifier and confirm it cannot broaden the trusted scope.
  6. Repeat for direct ID lookup and association loading.

Failure Modes

SymptomRiskFix
Tenant ID comes from request JSONCross-tenant data access.Resolve tenant from authenticated context and apply it as a trusted server constraint.
List query is scoped but ID lookup is notBoundary bypass.Enforce one context-installed runtime policy and test every entry path.
Child relation loads escape tenant scopeCross-tenant association exposure.Review generated relationships and nested request policy.
Background task has no identityUnowned read/write and weak audit trail.Create an explicit service identity and tenant scope in UserContext.

References