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

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:

LayerMechanismImplementation
ApplicationThread-local tenant contextTenantContextHolder with ThreadLocal<TenantContext>
DatabaseRow-level tenant filteringTenant ID column on every table, enforced by repositories
KubernetesNamespace-per-tenantDedicated K8s namespace with NetworkPolicy, ResourceQuota
NetworkNetwork policiesPer-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:

  1. JWT token carries the tenant_id claim (set at authentication time)
  2. SecurityFilter validates the request and extracts headers
  3. JWT authentication filter validates the token and extracts claims
  4. TenantContextHolder stores the tenant ID in a ThreadLocal for the request thread
  5. Service layer reads the tenant context from TenantContextHolder
  6. Repository layer appends the tenant filter to every database query
  7. Inter-service calls propagate the tenant ID via the X-Tenant-ID header
  8. 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

GuaranteeImplementationEnforcement Point
Data isolationEvery database row has a tenant_id columnRepository layer with mandatory WHERE clause
Compute isolationPer-tenant K8s namespace with ResourceQuotaKubernetes scheduler
Network isolationNetworkPolicy restricts cross-namespace trafficKubernetes CNI
Storage isolationPer-tenant encryption keys with AADEncryptionService.encryptForTenant()
Session isolationRedis keys prefixed with tenant IDSession store
Cache isolationCache keys include tenant IDRedis caching layer
Audit isolationAudit events tagged with tenant IDAuditLogger

Security Invariants

The following invariants must hold at all times:

  1. No tenant context, no data access. Any request that reaches the service layer without a valid tenant context in TenantContextHolder results in an IllegalStateException.

  2. 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.

  3. Database queries always filter by tenant. Repositories use TenantContextHolder.requireTenantId() to get the tenant filter, which throws if no context is set.

  4. 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.

  5. Service tokens propagate context. When a service makes an inter-service call, it sets the X-Tenant-ID header from the current TenantContextHolder, ensuring the receiving service operates in the same tenant context.


Related Pages