欢迎光临
我们一直在努力

多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案

多租户系统最容易出现的误判是:给业务表加一个 tenant_id,再给页面加一个“切换公司”入口,就以为完成了多租户。实际运行时,同一账号可能加入多家公司;平台管理员和租户管理员管理的对象不同;某个功能即使出现在菜单上,也未必在当前套餐内;导出和后台任务又不在最初的请求线程里。任何一处只信前端传来的租户编号,都可能把两家公司的数据或管理权限混在一起。

本文先建立整体设计地图,回答系统要保存哪些对象、一次请求如何经过身份和授权边界、租户状态与套餐如何影响结果,并用一组固定数据验证。后续文章再分别深入开通、数据隔离、两级管理员、多级菜单、套餐和配额。这里不重复“单库还是多库”的部署选型;无论选哪种存储方式,业务请求仍要确定自己属于哪个租户。

示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x。代码和表名是教学用的最小模型,不对应任何产品的完整内部设计。案例中的公司、人员和单据均为虚构。

目录

  • 先看一个会出错的业务场景
  • 多租户系统的五类对象
  • 一次请求必须经过哪些判断
  • 数据隔离不能只靠列表条件
  • 平台权限、租户权限和套餐能力如何区分
  • 生命周期与订阅变化如何影响已有数据
  • 最小实现、自动测试与SQL核对
  • 上线验收清单与适用边界
  • 一、先看一个会出错的业务场景

    假设一个 SaaS 系统服务两家公司。账号 u_07 同时加入甲公司和乙公司,在甲公司是租户管理员,在乙公司只是普通查看者。甲公司的套餐允许使用“批量报表”,乙公司的套餐暂未开通。两家公司各有一张编号相似的申请单。

    对象甲公司 t_a乙公司 t_b
    u_07 的成员身份 管理员,可查看和审批本公司单据 查看者,只能查看授权范围内单据
    申请单 a_1001,归属 t_a b_1001,归属 t_b
    批量报表能力 已开通 未开通
    租户状态 ACTIVE ACTIVE

    现在 u_07 在乙公司页面把详情 URL 中的 b_1001 改成 a_1001;或者把请求头里的租户编号改成 t_a,试图在乙公司会话中调用甲公司的审批接口。这两种请求都不应因为“账号确实加入了甲公司”就直接成功:当前会话选定的租户、该租户中的成员权限、目标记录的归属,以及功能权益,需要按顺序核对。

    **本文预期结果:**乙公司会话查询甲公司单据返回不可见;乙公司会话发起批量报表被能力校验拒绝;切换到甲公司会话后可以查看甲公司授权范围内的数据,但管理员身份也不能越过单据归属或数据范围。平台管理员的运营权限另走受控入口,不能伪装成任意租户成员。

    在这里插入图片描述

    图1:账号可以相同,但当前租户、成员权限、业务数据和套餐能力必须分别判定。

    二、多租户系统的五类对象

    设计时先把“账号”和“租户”分开。账号回答“谁登录了”,租户回答“当前在为哪个组织操作”。两者之间的成员关系回答“这个人是否属于该组织、在该组织担任什么角色”。同一个账号可以有多条成员关系,而一个租户可以有多名成员。

    再把管理权限、业务数据和套餐权益分开:

    对象最小问题常见错误
    租户及生命周期 组织是否已开通、可用、冻结或停用 只检查登录成功,不检查租户是否可用
    账号与租户成员 登录者在当前租户中是什么身份 把账号的全局角色当作各租户的角色
    权限与数据范围 当前成员可执行什么动作、访问哪些记录 只控制菜单,不保护接口与详情
    套餐订阅与权益 当前租户拥有哪些功能和资源额度 把菜单可见等同于功能已开通
    业务资源 单据、文件、任务归属哪个租户 只给主表加租户字段,附件和导出漏掉

    这五类对象并不意味着要堆成五个独立服务。小系统可以先在一个服务内清晰划分模块;复杂系统可按职责拆分。关键是对象语义不能混用:租户管理员是权限身份,不是套餐;套餐开通的是产品能力,不自动授予用户审批权限;租户状态决定是否允许新操作,不等于把历史数据删除。

    在这里插入图片描述

    图2:成员关系连接账号与租户,权限和订阅分别约束“谁能做”和“产品允许做什么”。

    三、一次请求必须经过哪些判断

    用 u_07 在甲公司审批 a_1001 为例,一次请求可拆成六个判断,顺序也有意义:

  • **认证账号。**确认请求来自已登录账号 u_07,不把前端填写的用户 ID 当作身份。
  • **确定当前租户。**从受信会话或经过校验的租户选择结果得到 t_a;如果允许切换租户,切换时重新验证成员关系并更新会话,不能只改一个请求头。
  • **检查租户与成员状态。**租户必须可用,成员必须有效;停用成员即使持有旧会话,也不能继续新操作。
  • **检查动作权限。**在当前租户内确认 u_07 有审批动作;平台级权限不能自动折算为租户级审批权。
  • **检查业务资源归属与数据范围。**按 tenant_id=t_a AND id=a_1001 找记录,再核对该成员可访问这张单据;查不到时不返回另一租户的存在信息。
  • **检查产品能力与执行条件。**若审批属于需订阅功能,检查当前生效权益;若有配额,还要在真正提交时作并发安全的额度校验。
  • 认证、授权、权益和数据归属不是同一把锁。即使 u_07 在甲、乙两家公司都是合法成员,也不能省掉当前租户的确定;即使有菜单权限,也不能省掉业务资源的归属检查。列表、详情、修改、下载、导出及异步任务都要遵循同一套租户边界。

    在这里插入图片描述

    图3:任何一步失败都应停止执行;页面入口只是提示,不是服务器端授权证据。

    四、数据隔离不能只靠列表条件

    最常见的补丁是在列表查询增加 WHERE tenant_id = ?。这必要,却远远不够。详情通常通过单据 ID 直查;修改可能先按 ID 更新再校验;附件可能用对象地址下载;导出和后台任务可能在用户离开页面后继续执行。只要其中一条路径缺少租户归属约束,列表再正确也挡不住串数据。

    一个实用原则是:**资源的创建、读取、修改和交付都携带可信的租户边界。**写入时由服务端赋予 tenant_id;读取和更新时将 tenant_id 与资源 ID 一起作为定位条件;附件保存所属租户和所属业务对象;缓存键、搜索索引、消息和后台任务也显式携带租户标识。不能用客户端给出的 tenant_id 覆盖服务端已确认的身份。

    若采用共享数据库,业务表中的 tenant_id、组合索引和更新条件是基本防线。若采用独立数据库,也需要在连接路由前正确确认租户身份,且跨租户平台操作要有单独的审批与审计。两种部署方式解决的是不同层面的隔离,不能替代接口授权。

    — 教学用最小结构;实际字段和索引应依据查询模式调整。
    CREATE TABLE sample_tenant (
    tenant_id VARCHAR(32) PRIMARY KEY,
    status VARCHAR(20) NOT NULL
    );

    CREATE TABLE sample_request (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    request_no VARCHAR(40) NOT NULL,
    status VARCHAR(20) NOT NULL,
    UNIQUE KEY uk_tenant_request_no (tenant_id, request_no),
    UNIQUE KEY uk_tenant_request_id (tenant_id, id),
    CONSTRAINT fk_request_tenant FOREIGN KEY (tenant_id)
    REFERENCES sample_tenant (tenant_id)
    );

    CREATE TABLE sample_file (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    request_id BIGINT NOT NULL,
    UNIQUE KEY uk_file_tenant_id (tenant_id, id),
    CONSTRAINT fk_file_request FOREIGN KEY (tenant_id, request_id)
    REFERENCES sample_request (tenant_id, id)
    );

    — 详情:必须同时使用可信租户 ID 与业务记录 ID。
    SELECT id, request_no, status
    FROM sample_request
    WHERE tenant_id = :currentTenantId AND id = :requestId;

    — 修改:以影响行数判断是否仍存在且状态允许推进。
    UPDATE sample_request
    SET status = 'APPROVED'
    WHERE tenant_id = :currentTenantId
    AND id = :requestId
    AND status = 'PENDING';

    上述 SQL 只能保证不会按另一个租户的记录 ID 直接改到那条记录;审批人范围、状态机和套餐能力仍应由服务层或更严格的数据访问层校验。尤其不要把“影响 0 行”直接解释为“记录不存在”:也可能是记录属于别的租户、状态已变化,或版本已冲突。对外错误应避免泄露跨租户信息,对内日志保留可审计的原因码。

    五、平台权限、租户权限和套餐能力如何区分

    企业 SaaS 至少存在两个管理面。平台管理员维护租户开通、套餐模板、运行状态等平台对象;租户管理员在自己的租户内管理成员、角色和业务配置。平台管理员不应因为“级别更高”就无条件拥有每家租户的业务单据读写权。需要排查租户问题时,应通过受限、留痕的支持授权进入,而不是默默绕过租户边界。

    多级菜单也容易混淆三个判断:

    判断回答什么甲公司批量报表示例
    套餐能力 甲公司是否购买/开通该能力 有效订阅包含 BATCH_REPORT
    租户内权限 u_07 在甲公司能否使用该能力 角色包含“报表生成”动作
    数据范围 生成的报表能包含哪些记录 只能包含甲公司且在授权范围内的数据

    三项都成立,操作才可以执行。菜单展示可依据前两项做友好提示,但不能代替接口端的判断;报表下载还要再次校验文件归属和访问权。套餐到期时可以停止创建新报表,但如何处理已生成文件、进行中的任务,需要提前写成明确规则,不能由某个页面临时决定。

    在这里插入图片描述

    图4:两级管理员管理不同对象;能看见入口、能执行动作和能读取数据是三件事。

    六、生命周期与订阅变化如何影响已有数据

    租户不是只有“存在/不存在”两种状态。以开通流程为例,注册成功只代表取得租户编号;管理员建立、基础配置就绪、订阅生效、必要资源可用之后,才适合进入 ACTIVE。任何一步失败都应保留可恢复的状态与失败原因,避免页面宣告成功而后台尚不可用。

    运行中还会遇到升级、降级、到期、冻结和停用。一个可解释的设计应分别回答:新操作能否发起?历史数据能否读取?正在执行的任务如何处理?已有文件保留多久?管理员能否导出必要记录?这些问题不宜全部由一个 enabled 布尔值决定。

    变化新操作历史数据需要额外核对
    套餐升级 按生效时间开放新增能力 不应改写旧记录归属 新权益何时生效、缓存何时刷新
    套餐降级或到期 禁止超出当前权益的新操作 原则上保留,读取政策需明确 运行任务、已生成产物及超额资源
    租户冻结 暂停受限业务操作 按合同与安全策略保留 后台任务、消息发送、文件访问
    租户恢复 通过核验后恢复允许的操作 检查冻结期间状态一致性 订阅是否仍有效、队列是否重放

    表中是设计讨论的起点,不是适用于所有 SaaS 的合同条款。尤其涉及数据保留与删除,应由产品政策、客户约定和适用法规共同确定,工程实现只负责让规则可执行、可追踪。

    七、最小实现、自动测试与SQL核对

    下面的示例只展示业务服务层的检查顺序。TenantSession 必须由认证层根据登录者、成员关系和租户切换结果构造;不能从请求体直接实例化。Rights 是预先解析的权限与套餐能力快照,实际系统还需定义版本或失效策略,避免权益变化后旧会话长期有效。

    record TenantSession(String userId, String tenantId,
    boolean activeTenant, boolean activeMember) {}
    record Rights(boolean canApprove, boolean approvalFeatureEnabled) {}
    record RequestRow(long id, String tenantId, String status) {}

    final class ApprovalService {
    private final RequestRepository requests;

    ApprovalService(RequestRepository requests) { this.requests = requests; }

    void approve(TenantSession session, Rights rights, long requestId) {
    if (!session.activeTenant()) throw new AccessDeniedException("TENANT_INACTIVE");
    if (!session.activeMember()) throw new AccessDeniedException("MEMBER_INACTIVE");
    if (!rights.approvalFeatureEnabled()) throw new AccessDeniedException("FEATURE_DISABLED");
    if (!rights.canApprove()) throw new AccessDeniedException("ACTION_FORBIDDEN");

    RequestRow row = requests.findByTenantAndId(session.tenantId(), requestId)
    .orElseThrow(() -> new AccessDeniedException("RESOURCE_UNAVAILABLE"));
    if (!"PENDING".equals(row.status())) throw new IllegalStateException("STATE_CHANGED");
    if (requests.approveIfPending(session.tenantId(), row.id()) != 1)
    throw new IllegalStateException("STATE_CHANGED");
    }
    }

    interface RequestRepository {
    Optional<RequestRow> findByTenantAndId(String tenantId, long id);
    int approveIfPending(String tenantId, long id);
    }

    示例刻意不把平台管理员作为特权分支塞进 approve:跨租户支持访问应走单独的受控流程。代码中 findByTenantAndId 体现读边界,approveIfPending 体现写边界和并发状态约束。activeTenant 应由可信租户状态服务生成,生产环境还要按业务要求对数据范围作进一步判断。AccessDeniedException 可用 Spring Security 对应异常;若脱离该框架,可替换为项目自己的拒绝异常。

    最少应有以下自动测试,测试数据固定为 a_1001 属于 t_a、b_1001 属于 t_b:

    @Test
    void bSessionCannotApproveARequestEvenWhenSameUserBelongsToBothTenants() {
    var bSession = new TenantSession("u_07", "t_b", true, true);
    var bRights = new Rights(true, true); // 即使误授予动作权限,资源归属仍要挡住越界
    when(repository.findByTenantAndId("t_b", 1001L)).thenReturn(Optional.empty());
    assertThatThrownBy(() -> service.approve(bSession, bRights, 1001L))
    .isInstanceOf(AccessDeniedException.class);
    verify(repository).findByTenantAndId("t_b", 1001L);
    verify(repository, never()).approveIfPending(anyString(), anyLong());
    }

    @Test
    void aSessionApprovesOnlyItsPendingRequest() {
    var aSession = new TenantSession("u_07", "t_a", true, true);
    var aRights = new Rights(true, true);
    when(repository.findByTenantAndId("t_a", 1001L))
    .thenReturn(Optional.of(new RequestRow(1001L, "t_a", "PENDING")));
    when(repository.approveIfPending("t_a", 1001L)).thenReturn(1);
    service.approve(aSession, aRights, 1001L);
    verify(repository).approveIfPending("t_a", 1001L);
    }

    @Test
    void menuPermissionCannotOverrideExpiredFeature() {
    var aSession = new TenantSession("u_07", "t_a", true, true);
    assertThatThrownBy(() -> service.approve(aSession, new Rights(true, false), 1001L))
    .isInstanceOf(AccessDeniedException.class);
    verifyNoInteractions(repository);
    }

    @Test
    void frozenTenantRejectsOldSessionBeforeReadingBusinessData() {
    var frozen = new TenantSession("u_07", "t_a", false, true);
    assertThatThrownBy(() -> service.approve(frozen, new Rights(true, true), 1001L))
    .isInstanceOf(AccessDeniedException.class);
    verifyNoInteractions(repository);
    }

    这四个测试分别证明跨租户拒绝、合法路径可推进、功能未开通及租户被冻结时不访问业务数据。它们没有覆盖租户切换、附件下载或异步任务,那些入口必须另加接口或集成测试,不能因为服务层单测通过就宣布隔离完成。

    上线前还可对共享库做两类巡检。第一类找缺少租户归属或与主单据归属不一致的附件;第二类检查业务记录是否关联有效租户。预期结果均为 0 行,否则应先修复数据与写入路径,再开放新功能。

    SELECT f.id, f.tenant_id AS file_tenant, r.tenant_id AS request_tenant
    FROM sample_file f
    JOIN sample_request r ON r.id = f.request_id
    WHERE f.tenant_id <> r.tenant_id;

    SELECT r.id, r.tenant_id
    FROM sample_request r
    LEFT JOIN sample_tenant t ON t.tenant_id = r.tenant_id
    WHERE t.tenant_id IS NULL;

    这里的三张表只展示租户、业务单据和附件的关键约束,不含真实产品的全部字段。复合外键可拦截“附件写在甲租户、却关联乙租户单据”的数据错误;真实系统还应检查导出结果、缓存键和任务记录,但不要用一条大 SQL 假装覆盖所有存储介质。

    在这里插入图片描述

    图5:验收应同时包含允许路径与拒绝路径,并核对数据、权限和权益三个结果。

    八、上线验收清单与适用边界

    真正上线时,至少用两家虚构租户、一个跨组织账号、不同成员角色、不同套餐和同名业务编号走完下面的矩阵:

    场景预期结果验证证据
    在甲公司会话查看甲公司授权单据 成功 响应归属、查询日志与当前租户一致
    在乙公司会话猜测甲公司单据 ID 拒绝且不泄露存在性 接口测试与拒绝原因码
    乙公司未开通批量报表却直调接口 拒绝,不创建任务 权益校验记录、任务表无新增
    租户管理员授予平台级权限 拒绝 授权范围测试、审计记录
    冻结乙公司后旧会话继续请求 按冻结政策拒绝新操作 会话与租户状态联动测试
    导出/后台任务在用户离开页面后执行 仍使用创建时已确认的租户身份 任务记录、文件归属及下载授权

    如果只有单租户部署,或客户明确要求独立环境,可以简化部分跨租户运行机制,但仍要清楚区分账号身份、角色权限、资源归属与产品权益。反过来,若已经存在跨组织协作、平台代运营或复杂合同规则,上述最小模型只是起点,必须补上受控授权、审计、保留政策及更严格的隔离措施。

    多租户的核心不是一个字段或一种数据库形态,而是让每次操作都能回答四个问题:**谁在操作、代表哪个租户、能碰哪些资源、当前权益是否允许。**把这四个答案贯穿页面、接口、数据、文件和后台任务,后续的开通、两级管理员与套餐控制才有稳定落点。

    参考资料:

  • Microsoft Learn:多租户方案的租赁模型与隔离权衡
  • Microsoft Learn:多租户方案中的身份与授权
  • OWASP:多租户安全检查清单
  • Spring Security:方法级授权
  • 赞(0)
    未经允许不得转载:171主机测评 » 多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址