Tenant Context Propagation
The TenantContextHolder is the central mechanism that ensures every operation in the MATIH Platform executes within the correct tenant boundary. It uses Java's ThreadLocal pattern to store the tenant context for the duration of a request and provides utilities for context propagation across thread boundaries, async operations, and inter-service calls.
Thread-Local Storage
The tenant context is stored in a ThreadLocal variable, which provides thread-safe, per-request isolation:
// From TenantContextHolder.java
public final class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
}The ThreadLocal guarantees that:
- Each HTTP request thread has its own tenant context
- Concurrent requests from different tenants do not interfere
- Context is not shared between request processing threads
Setting the Context
The context is set in three ways, depending on the scenario:
From JWT Token (standard request flow)
// In the JWT authentication filter
ValidationResult result = validator.validateAccessToken(token);
String tenantId = result.getClaims().get("tenant_id", String.class);
String userId = result.getClaims().getSubject();
TenantContextHolder.setTenant(tenantId, userId);From HTTP Header (inter-service calls)
// In the inter-service request filter
String tenantId = request.getHeader("X-Tenant-ID");
String userId = request.getHeader("X-User-ID");
if (tenantId != null) {
TenantContextHolder.setTenant(tenantId, userId);
}With Full Context (programmatic)
TenantContextHolder.setContext(new TenantContext(
"acme-corp",
"user-123",
Map.of("session_id", "sess-abc", "data_classification", "internal")
));Reading the Context
The TenantContextHolder provides both optional and required access patterns:
Optional Access
// Returns Optional.empty() if no context is set
Optional<TenantContext> context = TenantContextHolder.getContext();
Optional<String> tenantId = TenantContextHolder.getTenantId();
Optional<String> userId = TenantContextHolder.getUserId();Required Access (fails fast)
// Throws IllegalStateException if no context is set
TenantContext context = TenantContextHolder.requireContext();
String tenantId = TenantContextHolder.requireTenantId();The requireContext() method is used in the repository layer to ensure that database queries always include a tenant filter:
// In a repository method
public List<Dashboard> findAll() {
String tenantId = TenantContextHolder.requireTenantId();
return jdbcTemplate.query(
"SELECT * FROM dashboards WHERE tenant_id = ?",
tenantId
);
}Existence Check
boolean hasContext = TenantContextHolder.hasContext();Clearing the Context
Context must always be cleared after the request completes to prevent leakage. This is done in the authentication filter's finally block:
try {
TenantContextHolder.setTenant(tenantId, userId);
filterChain.doFilter(request, response);
} finally {
TenantContextHolder.clear();
}The clear() method calls ThreadLocal.remove(), which is important for thread pool environments where threads are reused across requests.
Context Propagation to Child Threads
When work is dispatched to background threads (e.g., via CompletableFuture, ExecutorService, or @Async methods), the tenant context does not automatically propagate because ThreadLocal is bound to the originating thread.
Using withTenant for Synchronous Closures
// Execute a block of code with a specific tenant context
String result = TenantContextHolder.withTenant("acme-corp", () -> {
// Inside this block, TenantContextHolder.getTenantId() returns "acme-corp"
return someService.processData();
});
// After withTenant returns, the previous context is restoredUsing wrapWithContext for Async Operations
The wrapWithContext methods capture the current tenant context and restore it in the target thread:
// Wrap a Runnable to preserve tenant context
Runnable wrapped = TenantContextHolder.wrapWithContext(() -> {
// This code will run with the original tenant context
processInBackground();
});
executorService.submit(wrapped);// Wrap a Callable to preserve tenant context
Callable<Result> wrapped = TenantContextHolder.wrapWithContext(() -> {
// This code will run with the original tenant context
return computeResult();
});
Future<Result> future = executorService.submit(wrapped);Implementation Detail
The wrapWithContext methods capture the context at wrap time and set/restore it at execution time:
// From TenantContextHolder.java
public static Runnable wrapWithContext(Runnable runnable) {
TenantContext context = CONTEXT.get();
return () -> {
TenantContext old = CONTEXT.get();
try {
if (context != null) {
CONTEXT.set(context);
}
runnable.run();
} finally {
if (old != null) {
CONTEXT.set(old);
} else {
CONTEXT.remove();
}
}
};
}Context with Attributes
The TenantContext record supports arbitrary attributes for enriched context:
public record TenantContext(
String tenantId,
String userId,
Map<String, Object> attributes
) {
public Optional<Object> getAttribute(String key) {
return Optional.ofNullable(attributes.get(key));
}
public <T> Optional<T> getAttribute(String key, Class<T> type) {
Object value = attributes.get(key);
if (value != null && type.isInstance(value)) {
return Optional.of((T) value);
}
return Optional.empty();
}
public TenantContext withAttribute(String key, Object value) {
var newAttributes = new java.util.HashMap<>(attributes);
newAttributes.put(key, value);
return new TenantContext(tenantId, userId, newAttributes);
}
public TenantContext withUser(String newUserId) {
return new TenantContext(tenantId, newUserId, attributes);
}
}Common Attributes
| Attribute | Type | Set By | Used By |
|---|---|---|---|
session_id | String | Authentication filter | Audit logger |
ip_address | String | Authentication filter | Rate limiter, audit logger |
mfa_verified | Boolean | MFA filter | Permission evaluator |
data_classification | String | Data access layer | OPA policy engine |
Inter-Service Context Propagation
When one service calls another, the tenant context must be propagated. This is done via HTTP headers:
// When making an inter-service HTTP call
HttpHeaders headers = new HttpHeaders();
TenantContextHolder.getContext().ifPresent(ctx -> {
headers.set("X-Tenant-ID", ctx.tenantId());
if (ctx.userId() != null) {
headers.set("X-User-ID", ctx.userId());
}
});The receiving service extracts these headers and sets the local tenant context:
// In the receiving service's filter
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}Kafka Message Context
For asynchronous communication via Kafka, the tenant context is propagated as message headers:
// Producer
ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);
TenantContextHolder.getTenantId().ifPresent(
tenantId -> record.headers().add("X-Tenant-ID", tenantId.getBytes())
);
// Consumer
String tenantId = new String(record.headers().lastHeader("X-Tenant-ID").value());
TenantContextHolder.setTenantId(tenantId);Validation
The TenantContext constructor validates that the tenant ID is non-null and non-blank:
public TenantContext {
if (tenantId == null || tenantId.isBlank()) {
throw new IllegalArgumentException("Tenant ID cannot be null or blank");
}
attributes = attributes != null ? Map.copyOf(attributes) : Map.of();
}This prevents accidental creation of a context with an empty tenant ID, which would bypass all tenant isolation checks.
Related Pages
- Multi-Tenancy Patterns -- Overview of multi-tenancy architecture
- Database Isolation -- How tenant context drives database filtering
- Namespace Isolation -- Kubernetes namespace boundaries