多租户

该扩展组件支持构建多租户的 Jmix 应用程序,所有租户的数据都保存在单一数据库中。一个应用程序实例即可服务于多个租户,租户是指不同的用户组,组之间互相不可见,仅共享特定的(通常是只读的)数据。

多租户应用程序中有两种类型的数据:

  • 租户之间共享的通用数据。租户的用户对这些数据应该仅有只读权限。

  • 租户特有的数据,其他租户无法访问。租户享有对此类数据的全部权限。

安装

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

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

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

原理

项目中租户特有的实体必须有一个 String 类型的参数,用 @TenantId 注解标注。当租户用户加载此类实体时,框架会将租户 ID 的 WHERE 条件添加到 JPQL 查询语句中,仅读取用户租户的数据。另外,在保存新实体时,租户 ID 属性会自动设置为当前用户的租户。

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

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

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

  • EntityLogItem

  • SendingMessage

  • SendingAttachment

  • Report

  • ReportGroup

  • ResourceRoleEntity

  • RowLevelRoleEntity

  • FilterConfiguration

  • UiTablePresentation

租户管理

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

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

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

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

租户用户

不同租户中的用户可能会有相同的登录名。如需在整个应用程序中提供唯一的 username 属性,租户用户需要使用租户 ID 为前缀的 username 进行注册。例如,如果 t1t2 租户中都有叫 Alice 的不同用户,那么她们应该使用分别使用 t1|alicet2|alice 用户名。

租户用户可以有两种登录应用程序的方法:

  1. 打开登录界面时,使用租户 ID 作为 URL 参数,例如,http://localhost:8080/#login?tenantId=t1。然后用户可以输入不带租户 ID 前缀的用户名,例如,仅输入 alice

    可以使用 jmix.multitenancy.tenantIdUrlParamName 应用程序属性为该 URL 参数指定不同的名称。

  2. 用户填写包括租户 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-browse.xml 中的表格添加 tenant 列:

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

    <comboBox id="tenantField" property="tenant" editable="false"/>
  5. UserEdit 类添加下列代码:

    @Autowired
    private ComboBox<String> tenantField;
    
    @Autowired
    private TenantProvider tenantProvider;
    
    @Autowired
    private MultitenancyUiSupport multitenancyUiSupport;
    
    @Subscribe
    public void onBeforeShow(BeforeShowEvent event) {
        String currentTenantId = tenantProvider.getCurrentUserTenantId();
        if (!currentTenantId.equals(TenantProvider.NO_TENANT)
                && Strings.isNullOrEmpty(tenantField.getValue())) {
            tenantField.setEditable(false);
            tenantField.setValue(currentTenantId);
        }
    }
    
    @Subscribe("tenantField")
    public void onTenantFieldValueChange(HasValue.ValueChangeEvent<String> event) {
        usernameField.setValue(
                multitenancyUiSupport.getUsernameByTenant(
                        usernameField.getValue(), event.getValue()));
    }
    
    @Subscribe
    public void onInit(InitEvent event) {
        tenantField.setOptionsList(multitenancyUiSupport.getTenantOptions());
        // ...
    
    @Subscribe
    public void onInitEntity(InitEntityEvent<User> event) {
        tenantField.setEditable(true);
        // ...
  6. 如需支持 上面 介绍的不同租户相同登录名的功能,在 LoginScreen 类添加下列代码:

    @Autowired
    private MultitenancyUiSupport multitenancyUiSupport;
    
    @Autowired
    private UrlRouting urlRouting;
    
    private void login() {
        String username = usernameField.getValue();
        // ...
        // add tenantId prefix if it was provided in the URL
        username = multitenancyUiSupport.getUsernameByUrl(username, urlRouting);
    
        try {
            loginScreenSupport.authenticate(

安全配置

为租户用户配置角色时,需要在实体属性策略中排除租户 ID 属性,避免用户能查看该字段。例如,如果 Customer 实体是租户特定的实体,并具有 tenant 属性带 @TenantId 注解,则对于该实体访问权限的角色中,需要显式的列出所有的属性并忽略 tenant

@ResourceRole(name = "Users", code = "users", scope = "UI")
public interface UsersRole {
    // ...

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