多租户

该扩展组件支持构建多租户的 Jmix 应用程序,多个租户的数据都保存在单一数据库中。一个应用程序实例即可满足多个租户的需求,租户是指彼此隔离且只能访问特定(通常是只读)数据的用户组。

在一个多租户的 Jmix 应用程序中,有两种类型的数据:

  • 通用数据

    • 应用程序中所有租户之间共享的数据。

    • 租户对这些通用数据具有只读访问权限,这些数据可全局访问,但不能由单个租户修改。

  • 租户特有的数据

    • 每个租户的特定数据,对其他租户不可见或无法访问。

    • 租户可以完全访问自己的租户特定数据,他们可以根据需要与这些数据进行交互或修改,而不会影响属于其他租户的数据。

通过在 Jmix 应用程序中实现多租户,开发人员可以有效地管理和向多个客户或用户组提供服务,确保数据隔离、安全性和基于每个租户特定需求的个性化访问控制。这种方式优化了数据的结构和访问,同时维护了不同用户组的数据隐私和完整性。

REST API 扩展组件不能支持多租户。目前还无法按租户分隔实体实例。

安装

按照 扩展组件 章节的说明通过 Jmix 市场进行自动安装。

手动安装,在 build.gradle 添加下列依赖:

implementation 'io.jmix.multitenancy:jmix-multitenancy-starter'
implementation 'io.jmix.multitenancy:jmix-multitenancy-flowui-starter'

原理

在项目中,特定于租户的实体应包含带 @TenantId 注解的 String 类型属性。当租户用户加载这些实体时,框架会将 tenant-id 的 WHERE 条件添加到 JPQL 查询语句中,仅读取用户租户的数据。此外,在保存新实体时,tenant-id 属性会自动分配给当前用户的租户。

原生 SQL 不会自动过滤,因此租户用户不应该有权限访问任何能提供对原生 SQL 或 Groovy 代码访问的功能(包括 JMX 控制台、创建 报表 等)。

在项目中,User 实体必须包含 tenant-id 属性。所有租户用户的这个属性都需要设置为一个特定的值。属性值为空的用户(不属于任何租户)可以看到所有租户的数据,全局管理员 可以这样设置,方便配置所有租户和维护整个系统。

Jmix 模块中的下列实体具有 sysTenantId 属性,支持多租户:

  • EntityLogItem

  • SendingMessage

  • SendingAttachment

  • Report

  • ReportGroup

  • ResourceRoleEntity

  • RowLevelRoleEntity

  • FilterConfiguration

租户管理

该扩展组件提供了 Multitenancy(多租户) → Tenants(租户) 界面,支持全局管理员创建和编辑租户。

租户注册记录包含两个属性:

  • Tenant id(租户 ID) - 租户特定实体中使用的租户标识符。创建之后无法修改。

  • Tenant name(租户名称) - 租户的描述名称。

tenants view

租户用户

在多租户应用程序中,不同租户中的用户可以具有相同的登录名。如需确保 username 属性在整个应用程序中是唯一的,租户用户应使用包含 tenant-id 前缀的用户名进行注册。例如,如果在 t1t2 两个租户中有两个名为 Alice 的不同用户,则其用户名应分别为 t1|alicet2|alice

租户用户可以使用完整的用户名(包括 tenant-id,例如 t1|alice)来登录应用程序。

租户用户可通过两种方式登录应用程序:

  1. 第一种方法是在访问登录屏幕时使用包含 tenant-id 的 URL 参数,例如 http://localhost:8080/login?tenantId=t1 。此时,用户只需输入不带 tenant-id 前缀的登录名,例如 alice

    可以使用 jmix.multitenancy.tenantIdUrlParamName 应用程序属性来设置 URL 参数的自定义名称。

  2. 用户可以提供完整的用户名,包括 tenant-id,例如 t1|alice

除上述方法之外,还可以实现自定义的唯一用户名结构。

用户配置

本节中,我们介绍在项目中配置用户管理和认证以支持多租户的流程。

  1. User 实体中添加一个属性,并使用 @TenantId 注解:

    @TenantId
    @Column(name = "TENANT")
    private String tenant;
    
    public String getTenant() {
        return tenant;
    }
    
    public void setTenant(String tenant) {
        this.tenant = tenant;
    }
  2. User 实体实现 io.jmix.multitenancy.core.AcceptsTenant 接口。getTenantId() 方法必须返回带 @TenantId 注解的属性:

    public class User implements JmixUserDetails, HasTimeZone, AcceptsTenant {
        // ...
    
        @Override
        public String getTenantId() {
            return tenant;
        }
    }
  3. user-list-view.xml 中的数据网格添加 tenant 列:

    <column property="tenant"/>
  4. user-detail-view.xml 中添加一个选择租户的字段:

    <comboBox id="tenantField" property="tenant" readOnly="true"/>
  5. UserDetailView 类添加下列代码:

    @ViewComponent
    private JmixComboBox<String> tenantField;
    
    @Autowired
    private TenantProvider tenantProvider;
    
    @Autowired
    private MultitenancyUiSupport multitenancyUiSupport;
    
    @Subscribe
    public void onInit(final InitEvent event) {
        timeZoneField.setItems(List.of(TimeZone.getAvailableIDs()));
        tenantField.setItems(multitenancyUiSupport.getTenantOptions());
    }
    
    @Subscribe
    public void onInitEntity(final InitEntityEvent<User> event) {
        tenantField.setReadOnly(false);
        usernameField.setReadOnly(false);
        passwordField.setVisible(true);
        confirmPasswordField.setVisible(true);
    }
    
    @Subscribe
    public void onBeforeShow(final BeforeShowEvent event) {
        String currentTenantId = tenantProvider.getCurrentUserTenantId();
        if (!currentTenantId.equals(TenantProvider.NO_TENANT)
                && Strings.isNullOrEmpty(tenantField.getValue())) {
            tenantField.setReadOnly(true);
            tenantField.setValue(currentTenantId);
        }
    }
    
    @Subscribe("tenantField")
    public void onTenantFieldComponentValueChange(final AbstractField.ComponentValueChangeEvent<JmixComboBox<String>, String> event) {
        usernameField.setValue(
                multitenancyUiSupport.getUsernameByTenant(usernameField.getValue(), event.getValue())
        );
    }
  6. 如需 按照之前解释的,支持在各个租户之间使用相同的登录名,请在 LoginView 类中添加以下代码:

    @Autowired
    private MultitenancyUiSupport multitenancyUiSupport;
    
    private Location currentLocation; (1)
    
    @Override
    public void beforeEnter(BeforeEnterEvent event) {
        currentLocation = event.getLocation();
        super.beforeEnter(event);
    }
    
    @Subscribe("login")
    public void onLogin(final LoginEvent event) {
        String username = multitenancyUiSupport.getUsernameByLocation(event.getUsername(), currentLocation);
    
        try {
            loginViewSupport.authenticate(
                    AuthDetails.of(username, event.getPassword())
                            .withLocale(login.getSelectedLocale())
                            .withRememberMe(login.isRememberMe())
            );
        } catch (BadCredentialsException | DisabledException | LockedException | AccessDeniedException e) {
            log.info("Login failed", e);
            event.getSource().setError(true);
        }
    }

安全配置

为租户用户设置角色时,请勿在实体属性策略中包含 tenant-id 属性,因为该属性需要对用户隐藏。例如,如果 Customer 属于租户特定实体,并且包含用 @TenantId 注解的 tenant 属性,则授予此实体访问权限的角色需要显式地指定包含的属性,且不要包含 tenant 属性:

@ResourceRole(name = "UsersRole", code = UsersRole.CODE, scope = "UI")
public interface UsersRole {
    String CODE = "users-role";

    @EntityAttributePolicy(entityClass = Customer.class,
            attributes = {"id", "name", "region", "version"},
            action = EntityAttributePolicyAction.MODIFY)
    @EntityPolicy(entityClass = Customer.class, actions = EntityPolicyAction.ALL)
    void customer();
}