Tenant Isolation
Tenant isolation is the foundational security guarantee of the MATIH Platform. In a multi-tenant system, each tenant's data, configuration, and workloads must be completely invisible and inaccessible to all other tenants. MATIH enforces this guarantee at every layer: Kubernetes namespaces, network policies, application-level context propagation, database row-level security, and cryptographic key separation.
Isolation Architecture
Tenant isolation in MATIH operates at four distinct layers:
| Layer | Mechanism | Enforcement |
|---|---|---|
| Infrastructure | Kubernetes namespace per tenant | Resource quotas, network policies, RBAC |
| Network | NetworkPolicy rules | Ingress/egress restrictions per namespace |
| Application | TenantContextHolder (thread-local) | Every database query, API call, and event filtered by tenant |
| Data | Per-tenant encryption keys, row-level filtering | Cryptographic isolation of data at rest |
Kubernetes Namespace Isolation
Each tenant in MATIH receives a dedicated Kubernetes namespace. This namespace serves as the primary isolation boundary for all tenant resources.
Namespace Structure
matih-system/ # Control plane services
matih-shared/ # Shared infrastructure (Kafka, databases)
tenant-acme-corp/ # Tenant: ACME Corporation
├── ai-service
├── query-engine
├── bi-service
├── ml-service
└── ... (all data plane services)
tenant-globex/ # Tenant: Globex Corporation
├── ai-service
├── query-engine
├── bi-service
└── ...Namespace-Level Controls
Each tenant namespace is configured with:
| Control | Purpose | Example |
|---|---|---|
| ResourceQuota | Limit CPU, memory, and storage per tenant | cpu: 8 cores, memory: 32Gi |
| LimitRange | Default and maximum resource limits for pods | default cpu: 500m, max cpu: 2 |
| NetworkPolicy | Restrict network communication | Only allow traffic within namespace + control plane |
| RBAC | Kubernetes role bindings | Tenant service accounts can only access own namespace |
| PodSecurityStandards | Pod security constraints | Enforce non-root containers, read-only filesystems |
Resource Quota Example
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-quota
namespace: tenant-acme-corp
spec:
hard:
requests.cpu: "8"
requests.memory: 32Gi
limits.cpu: "16"
limits.memory: 64Gi
persistentvolumeclaims: "10"
pods: "50"
services: "20"Network Policy
Network policies ensure that pods in one tenant namespace cannot communicate with pods in another tenant namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-isolation
namespace: tenant-acme-corp
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
# Allow traffic from within the same namespace
- from:
- podSelector: {}
# Allow traffic from the control plane
- from:
- namespaceSelector:
matchLabels:
matih.ai/role: control-plane
# Allow traffic from the tenant's ingress controller
- from:
- namespaceSelector:
matchLabels:
matih.ai/role: ingress
podSelector:
matchLabels:
matih.ai/tenant: acme-corp
egress:
# Allow DNS resolution
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
# Allow traffic within the namespace
- to:
- podSelector: {}
# Allow traffic to shared infrastructure
- to:
- namespaceSelector:
matchLabels:
matih.ai/role: shared-infraApplication-Level Tenant Context
While Kubernetes namespaces provide infrastructure isolation, application-level isolation ensures that every database query, API response, and event is scoped to the correct tenant. This is implemented through the TenantContextHolder and TenantContext classes.
TenantContextHolder
The TenantContextHolder uses Java's ThreadLocal to maintain tenant context throughout the lifecycle of a request:
public final class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
// Set tenant context at the beginning of a request
public static void setTenantId(String tenantId) {
CONTEXT.set(new TenantContext(tenantId, null, Map.of()));
}
// Get tenant context during request processing
public static Optional<String> getTenantId() {
return getContext().map(TenantContext::tenantId);
}
// Get required tenant ID (throws if not set)
public static String requireTenantId() {
return requireContext().tenantId();
}
// Clear context at the end of a request
public static void clear() {
CONTEXT.remove();
}
}TenantContext Record
The TenantContext record carries the complete tenant context:
public record TenantContext(
String tenantId, // Required: tenant identifier
String userId, // Optional: current user ID
Map<String, Object> attributes // Optional: additional context
) {
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();
}
}Request Lifecycle
The tenant context is established at the very beginning of each request and cleared at the end:
HTTP Request arrives with JWT token
|
v
[SecurityFilter] -- Extract JWT claims
|
v
[TenantContextHolder.setContext()] -- Set tenant from JWT "tenant_id" claim
|
v
[Controller] --> [Service] --> [Repository] --> [Database]
| | | |
| All layers access TenantContextHolder.getTenantId()
| to scope their operations to the current tenant
|
v
[TenantContextHolder.clear()] -- Clean up after request completesContext Propagation
The TenantContext persistence utility provides convenient access methods for the data access layer:
// In a repository or service method
String tenantId = TenantContext.requireCurrentTenantId();
// Check if running as system tenant
if (TenantContext.isSystemTenant()) {
// System-level operations (cross-tenant queries, admin tasks)
}
// Execute with a different tenant context
TenantContext.withTenant("other-tenant", () -> {
// All operations in this block use "other-tenant" context
// Original context is restored when the block completes
});
// Execute as system tenant
TenantContext.asSystem(() -> {
// Operations here use the "system" tenant context
// Useful for cross-tenant aggregation, admin operations
});Async Context Propagation
When operations are dispatched to background threads, the tenant context must be explicitly propagated. The TenantContextHolder provides wrappers for this:
// Wrap a Runnable to preserve tenant context
Runnable contextAware = TenantContextHolder.wrapWithContext(() -> {
// This code runs in a different thread but has the correct tenant context
String tenantId = TenantContextHolder.requireTenantId();
processDataForTenant(tenantId);
});
executorService.submit(contextAware);
// Wrap a Callable to preserve tenant context
Callable<Result> contextAwareCallable = TenantContextHolder.wrapWithContext(() -> {
return expensiveComputation();
});
Future<Result> future = executorService.submit(contextAwareCallable);Without these wrappers, background threads would have no tenant context, which could lead to unauthorized data access or application errors.
System Tenant
The SYSTEM_TENANT constant ("system") represents the platform itself, used for operations that are not scoped to any specific tenant:
| Use Case | Example |
|---|---|
| Cross-tenant aggregation | Platform-wide usage metrics |
| Admin operations | Tenant provisioning, billing calculations |
| System maintenance | Database migrations, cache warming |
| Scheduled tasks | Health checks, cleanup jobs |
// Execute as system tenant
TenantContext.asSystem(() -> {
List<TenantUsage> allUsage = usageRepository.findAll();
generateBillingReport(allUsage);
});Access to the system tenant context is restricted to services running in the matih-system namespace and requires the super_admin role or specific system-level scopes.
Database Tenant Isolation
At the database layer, tenant isolation is enforced through multiple mechanisms:
Row-Level Security
Every tenant-scoped table includes a tenant_id column. The persistence layer automatically filters queries by the current tenant context:
-- Every query is automatically scoped
SELECT * FROM dashboards
WHERE tenant_id = 'acme-corp' -- Injected from TenantContext
AND id = 42;Hibernate Multi-Tenancy
The TenantIdentifierResolver integrates with Hibernate's multi-tenancy support to automatically resolve the current tenant for all database operations:
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
return TenantContext.getCurrentTenantIdOrDefault("system");
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}Schema-Per-Tenant Option
For tenants with stricter isolation requirements, MATIH supports schema-per-tenant isolation where each tenant has its own database schema:
PostgreSQL Database
├── schema: tenant_acme_corp
│ ├── dashboards
│ ├── queries
│ └── ...
├── schema: tenant_globex
│ ├── dashboards
│ ├── queries
│ └── ...
└── schema: system
├── tenants
├── users
└── ...Event-Level Isolation
All events published to Kafka include the tenant ID, and consumers filter events by tenant:
Event Envelope
{
"eventId": "evt_abc123",
"tenantId": "acme-corp",
"eventType": "QUERY_EXECUTED",
"timestamp": "2026-02-10T14:30:00Z",
"payload": {
"queryId": "q_456",
"executionTimeMs": 230
}
}Topic-Per-Tenant Strategy
For high-isolation requirements, Kafka topics can be partitioned by tenant:
| Strategy | Topic Pattern | Isolation Level |
|---|---|---|
| Shared topic with tenant filter | matih.events (filter by tenantId) | Standard |
| Tenant-prefixed topics | acme-corp.events, globex.events | Enhanced |
| Dedicated Kafka clusters | Separate Kafka for each tenant | Maximum |
Service Mesh Isolation
In service-to-service communication, tenant context is propagated through HTTP headers:
| Header | Purpose |
|---|---|
X-Tenant-Id | Current tenant identifier |
X-Correlation-Id | Request correlation for tracing |
Authorization | Service-to-service JWT with tenant claim |
Services validate that the tenant ID in the header matches the tenant ID in the JWT token. Any mismatch results in an immediate rejection.
Zero-Trust Enforcement
The ZeroTrustSecurityService enforces zero-trust principles across all service interactions:
- Verify explicitly. Every request is authenticated and authorized, regardless of network location.
- Least privilege access. Service tokens carry only the scopes needed for the specific call.
- Assume breach. Network policies restrict lateral movement even within a tenant namespace.
Service A (tenant-acme-corp)
|
|-- Service Token (5-min lifetime, scoped to sql:execute)
|-- X-Tenant-Id: acme-corp
|-- X-Correlation-Id: req_789
v
Service B (tenant-acme-corp)
|-- Validates JWT signature
|-- Verifies tenant_id matches header
|-- Checks scopes include sql:execute
|-- Proceeds with tenant-scoped operationCross-Tenant Protection Patterns
Defense Against Tenant ID Manipulation
The platform protects against attempts to access another tenant's data:
| Attack Vector | Protection |
|---|---|
| Modified JWT tenant_id claim | JWT signature verification |
| Spoofed X-Tenant-Id header | Header must match JWT claim |
| Direct database query injection | Row-level security, parameterized queries |
| API parameter manipulation | Server-side tenant context from JWT, not from request |
| Event replay from another tenant | Event tenant_id verified against consumer's tenant |
Key Principle: Tenant ID Comes from the Token
The tenant ID is never taken from user input (query parameters, request body, headers submitted by clients). It is always extracted from the validated JWT token by the server:
// CORRECT: Tenant from JWT token (set by SecurityFilter)
String tenantId = TenantContextHolder.requireTenantId();
// INCORRECT: Never trust client-provided tenant ID
// String tenantId = request.getParameter("tenantId"); // DO NOT DO THISTesting Tenant Isolation
The platform includes integration tests that verify tenant isolation:
@Test
void shouldNotAccessOtherTenantData() {
// Create data as tenant A
TenantContext.withTenant("tenant-a", () -> {
dashboardService.create(new Dashboard("My Dashboard"));
});
// Attempt to access as tenant B
TenantContext.withTenant("tenant-b", () -> {
List<Dashboard> dashboards = dashboardService.findAll();
assertThat(dashboards).isEmpty(); // Tenant B sees nothing from Tenant A
});
}
@Test
void shouldIsolateConcurrentRequests() {
// Simulate concurrent requests from different tenants
CompletableFuture<String> futureA = CompletableFuture.supplyAsync(
TenantContextHolder.wrapWithContext(() -> {
TenantContextHolder.setTenantId("tenant-a");
return TenantContextHolder.requireTenantId();
})
);
CompletableFuture<String> futureB = CompletableFuture.supplyAsync(
TenantContextHolder.wrapWithContext(() -> {
TenantContextHolder.setTenantId("tenant-b");
return TenantContextHolder.requireTenantId();
})
);
assertThat(futureA.join()).isEqualTo("tenant-a");
assertThat(futureB.join()).isEqualTo("tenant-b");
}Monitoring Tenant Isolation
Isolation violations are detected through:
| Signal | Detection Method |
|---|---|
| Cross-tenant data access attempts | Audit log analysis for tenant mismatch errors |
| Network policy violations | Kubernetes audit logs, Calico/Cilium flow logs |
| Unusual API patterns | Anomaly detection on per-tenant API usage |
| Permission escalation | Alert on role changes and impersonation events |
Metrics are exposed per tenant for monitoring:
matih_tenant_requests_total{tenant="acme-corp", status="200"}
matih_tenant_requests_total{tenant="acme-corp", status="403"}
matih_tenant_isolation_violations_total{tenant="acme-corp"}Next Steps
Continue to Secret Management to learn how the platform securely manages credentials, API keys, and encryption keys across environments.