Multi-Tenancy Patterns
Multi-tenancy is the foundational architectural principle of the MATIH Platform. Every component, from the database layer to the Kubernetes infrastructure, is designed to securely isolate tenant data while sharing the same underlying compute resources. This section documents the multi-tenancy patterns, their implementation, and the security guarantees they provide.
Isolation Layers
The platform implements multi-tenancy at four distinct layers, providing defense in depth:
| Layer | Mechanism | Implementation |
|---|---|---|
| Application | Thread-local tenant context | TenantContextHolder with ThreadLocal<TenantContext> |
| Database | Row-level tenant filtering | Tenant ID column on every table, enforced by repositories |
| Kubernetes | Namespace-per-tenant | Dedicated K8s namespace with NetworkPolicy, ResourceQuota |
| Network | Network policies | Per-namespace ingress/egress rules, TLS everywhere |
Each layer operates independently. A failure at one layer does not compromise the others:
- If application-level filtering is bypassed (e.g., a SQL injection), the database-level tenant column still prevents cross-tenant data access.
- If a pod is compromised, the Kubernetes namespace boundary and network policies prevent lateral movement to other tenant namespaces.
- If a network policy is misconfigured, the application-level tenant context check still blocks unauthorized access.
Tenant Context Flow
The tenant context flows through the platform in a consistent pattern:
- JWT token carries the
tenant_idclaim (set at authentication time) - SecurityFilter validates the request and extracts headers
- JWT authentication filter validates the token and extracts claims
- TenantContextHolder stores the tenant ID in a
ThreadLocalfor the request thread - Service layer reads the tenant context from
TenantContextHolder - Repository layer appends the tenant filter to every database query
- Inter-service calls propagate the tenant ID via the
X-Tenant-IDheader - Audit logger includes the tenant context in every audit event
Client Request
|
v
[Authorization: Bearer <JWT with tenant_id>]
|
v
SecurityFilter (input validation)
|
v
JwtAuthFilter (token validation, tenant extraction)
|
v
TenantContextHolder.setTenant(tenantId, userId)
|
+---> Service Layer (reads TenantContextHolder)
| |
| +---> Repository (WHERE tenant_id = ?)
| |
| +---> Inter-service call (X-Tenant-ID header)
| |
| +---> AuditLogger (includes tenantId)
|
v
TenantContextHolder.clear() (in finally block)Tenant Context Holder
The TenantContextHolder class is the central mechanism for propagating tenant context through the application. It uses Java's ThreadLocal to store context that is scoped to the current request thread.
// From TenantContextHolder.java
public final class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
public static void setContext(TenantContext context) {
CONTEXT.set(context);
}
public static Optional<TenantContext> getContext() {
return Optional.ofNullable(CONTEXT.get());
}
public static TenantContext requireContext() {
TenantContext context = CONTEXT.get();
if (context == null) {
throw new IllegalStateException("No tenant context set");
}
return context;
}
public static void clear() {
CONTEXT.remove();
}
}TenantContext Record
The tenant context carries the tenant ID, optional user ID, and extensible attributes:
public record TenantContext(
String tenantId,
String userId,
Map<String, Object> attributes
) {
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();
}
}Isolation Guarantees
| Guarantee | Implementation | Enforcement Point |
|---|---|---|
| Data isolation | Every database row has a tenant_id column | Repository layer with mandatory WHERE clause |
| Compute isolation | Per-tenant K8s namespace with ResourceQuota | Kubernetes scheduler |
| Network isolation | NetworkPolicy restricts cross-namespace traffic | Kubernetes CNI |
| Storage isolation | Per-tenant encryption keys with AAD | EncryptionService.encryptForTenant() |
| Session isolation | Redis keys prefixed with tenant ID | Session store |
| Cache isolation | Cache keys include tenant ID | Redis caching layer |
| Audit isolation | Audit events tagged with tenant ID | AuditLogger |
Security Invariants
The following invariants must hold at all times:
-
No tenant context, no data access. Any request that reaches the service layer without a valid tenant context in
TenantContextHolderresults in anIllegalStateException. -
Tenant context is always cleared. The authentication filter sets the context in a try block and clears it in a finally block, preventing context leakage between requests.
-
Database queries always filter by tenant. Repositories use
TenantContextHolder.requireTenantId()to get the tenant filter, which throws if no context is set. -
Cross-tenant access is logged. Any attempt to access a resource belonging to a different tenant is logged as a security event, even if the access is denied.
-
Service tokens propagate context. When a service makes an inter-service call, it sets the
X-Tenant-IDheader from the currentTenantContextHolder, ensuring the receiving service operates in the same tenant context.
Related Pages
- Tenant Context Propagation -- Deep dive into
TenantContextHolder - Database Isolation -- Schema-per-tenant and row-level filtering
- Namespace Isolation -- Kubernetes namespace boundaries
- Resource Isolation -- CPU/memory quotas and priority classes
- Tenant Isolation -- Original isolation overview