How ContiNew Admin Implements Multi-Tenant Data Isolation: Architecture and Code Analysis
ContiNew Admin achieves multi-tenant data isolation through a layered architecture combining HTTP header-based tenant resolution, thread-local context propagation, mandatory tenant ID columns in base entities, and scoped execution utilities, all configurable via TenantExtensionProperties and overridable using the @TenantIgnore annotation.
ContiNew Admin (continew-org/continew-admin) is an open-source admin template built on Spring Boot that supports multi-tenancy through a shared database schema with row-level security. The system ensures complete data isolation between tenants by intercepting every request, binding a tenant identifier to the executing thread, and automatically injecting that identifier into every database operation.
Tenant Identification and Configuration
The isolation process begins when a request enters the system. The DefaultTenantProvider class reads the tenant identifier from a configurable HTTP header and validates the tenant's operational status before creating a security context.
Resolving Tenants from HTTP Headers
According to the source code in continew-plugin/continew-plugin-tenant, the DefaultTenantProvider extracts the tenant code from the header defined in TenantExtensionProperties (default: X-Tenant-Code). If the header is absent, the system falls back to a default tenant ID (0).
@Data
@ConfigurationProperties(prefix = PropertiesConstants.TENANT)
public class TenantExtensionProperties {
private String tenantCodeHeader = "X-Tenant-Code";
private Long defaultTenantId = 0L;
public boolean isDefaultTenant() {
return Objects.equals(TenantContextHolder.getTenantId(), defaultTenantId);
}
}
The provider implementation at DefaultTenantProvider.java (lines 42-73) builds a TenantContext object containing the resolved tenantId, which is then stored for the duration of the request lifecycle.
Default Tenant Fallback
When no X-Tenant-Code header is present, TenantExtensionProperties.isDefaultTenant() checks whether the current context matches the configured defaultTenantId. This allows system-wide administrative operations to run without explicit tenant scoping while maintaining the same architectural constraints.
Thread-Local Context Propagation
Once resolved, the tenant ID must persist throughout the request thread without explicit parameter passing. ContiNew Admin delegates this to TenantContextHolder (provided by the continew-starter-extension-tenant module), which maintains the tenant state in a thread-local variable.
Accessing Tenant State in Services
Throughout the codebase, service implementations query the holder to determine whether tenant filtering should apply. For example, in MenuServiceImpl.java (lines 71-75), the service checks both whether tenants are enabled globally and whether the current context represents the default tenant before applying menu exclusions.
if (TenantContextHolder.isTenantEnabled() && !tenantExtensionProperties.isDefaultTenant()) {
query = query == null ? new MenuQuery() : query;
query.setExcludeMenuIdList(this.listExcludeTenantMenu());
}
This pattern ensures that tenant-specific logic executes only when the multi-tenant mode is active and the current user is not operating under the default system tenant.
Data-Level Isolation Mechanisms
Physical data separation occurs at the entity layer. Every business object that requires tenant isolation extends TenantBaseDO, which mandates the presence of a tenant_id column in the underlying database tables.
The TenantBaseDO Base Entity
Located at continew-common/src/main/java/top/continew/admin/common/base/model/entity/TenantBaseDO.java (lines 39-42), this base class defines the contractual requirement for tenant-aware entities:
@Data
public class TenantBaseDO extends BaseDO {
private Long tenantId;
}
All repository queries generated by the system's MyBatis-plus integration automatically append WHERE tenant_id = :tenantId when TenantContextHolder.isTenantEnabled() returns true. This transparent filtering prevents data leakage between tenants without requiring manual SQL modifications in business logic.
Service-Layer Query Guards
While the ORM handles automatic filtering for direct entity lookups, complex business logic may require explicit tenant checks. The RoleServiceImpl class, for instance, inspects UserContextHolder.isTenantAdmin() to restrict role visibility, ensuring that tenant administrators cannot view or modify super-administrator roles reserved for the default tenant.
Scoped Execution for Administrative Tasks
Certain operations—such as initializing a new tenant, cleaning tenant data, or running batch jobs—must execute within a specific tenant context regardless of the HTTP request context. ContiNew Admin provides TenantUtils.execute() for this purpose.
Temporary Context Switching
The TenantUtils.execute(Long tenantId, Runnable action) utility temporarily pushes the specified tenant ID onto the TenantContextHolder, executes the provided lambda, and then restores the previous context state. This prevents context leakage between sequential operations.
In TenantDataApiForSystemImpl.java (lines 84-99), the tenant initialization logic uses this utility to create default departments, roles, and users bound to the newly created tenant:
Long tenantId = tenant.getId();
TenantUtils.execute(tenantId, () -> {
Long deptId = this.initDeptData(tenant);
Long roleId = this.initRoleData(tenant);
Long userId = this.initUserData(tenant, roleId, deptId);
tenantApi.bindAdminUser(tenantId, userId);
});
Similarly, tenant cleanup operations wrap all deletion logic inside TenantUtils.execute() to ensure that DELETE statements filter correctly by tenant_id without affecting other tenants' data.
Bypassing Isolation with @TenantIgnore
Public endpoints that must operate outside tenant scoping—such as authentication, health checks, or captcha generation—use the @TenantIgnore annotation to bypass the tenant resolution filter.
Controller-Level Exemptions
When placed on a controller method, @TenantIgnore instructs the tenant resolution filter to skip context creation for that request. The CommonController.tenantEnabled() method (lines 86-101) uses this annotation to expose the system's tenant configuration status without requiring a valid tenant header:
@TenantIgnore
@GetMapping("/tenant/enable")
public Boolean tenantEnabled() {
return TenantContextHolder.isTenantEnabled();
}
This mechanism ensures that login flows can authenticate users and resolve their appropriate tenant context dynamically without being blocked by the isolation layer upfront.
Summary
- Tenant Resolution:
DefaultTenantProviderextracts the tenant ID from theX-Tenant-Codeheader (configurable viaTenantExtensionProperties) or falls back to a default tenant ID of0. - Context Propagation:
TenantContextHoldermaintains the tenant ID in thread-local storage, accessible throughout the service layer viagetTenantId()andisTenantEnabled(). - Entity Isolation: All tenant-scoped entities extend
TenantBaseDO, which enforces atenant_idcolumn; the ORM automatically filters queries by this column when the tenant context is active. - Administrative Utilities:
TenantUtils.execute()enables temporary context switching for tenant provisioning and cleanup tasks inTenantDataApiForSystemImpl. - Selective Bypass: The
@TenantIgnoreannotation allows specific endpoints like login and health checks to operate without tenant scoping.
Frequently Asked Questions
How does ContiNew Admin determine which tenant a request belongs to?
The system examines the HTTP request header specified in TenantExtensionProperties.tenantCodeHeader (default X-Tenant-Code). The DefaultTenantProvider class reads this value, validates the tenant's existence and status, and constructs a TenantContext containing the tenant ID. If the header is missing, the provider falls back to the defaultTenantId (typically 0).
What happens if a database table does not have a tenant_id column?
All entities requiring isolation must extend TenantBaseDO, which defines the private Long tenantId field mapped to a tenant_id database column. If a table lacks this column and the system attempts to perform a tenant-scoped operation, the query will fail. Tables that store global system data (not tenant-specific) should use the base BaseDO class instead, though service methods must explicitly handle access control for such entities.
Can an administrator perform operations across multiple tenants simultaneously?
No, the architecture enforces single-tenant context per thread. However, administrators can use TenantUtils.execute(tenantId, () -> { ... }) to iterate over tenants sequentially. This utility temporarily sets the tenant context for the duration of the lambda execution, then restores the previous state. The TenantDataApiForSystemImpl.init() method demonstrates this pattern when initializing default data for a newly created tenant.
How do public endpoints like login avoid tenant isolation?
Controller methods annotated with @TenantIgnore bypass the tenant resolution filter entirely. This allows endpoints such as /captcha, /auth/login, and /tenant/enable in CommonController and CaptchaController to execute without a valid tenant header, enabling pre-authentication operations that may need to determine or establish tenant context dynamically.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →