MATIH Platform is in active MVP development. Documentation reflects current implementation status.
3. Security & Multi-Tenancy
multi-tenancy
Tenant Context Propagation

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 restored

Using 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

AttributeTypeSet ByUsed By
session_idStringAuthentication filterAudit logger
ip_addressStringAuthentication filterRate limiter, audit logger
mfa_verifiedBooleanMFA filterPermission evaluator
data_classificationStringData access layerOPA 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