跳到主要内容

设计多租户 SaaS 前端架构

问题

如何设计一个支持多个企业租户、白标主题、差异化功能、独立权限和灰度发布,同时避免跨租户数据泄露的 SaaS 前端?

面试速答版

多租户前端的关键不是切换一套颜色,而是让 Tenant Context 成为所有资源访问的必选作用域

  • 从可信 Host、登录会话或租户选择中解析租户,不信任任意 Query 参数;
  • Bootstrap 一次返回租户身份、用户、权限、主题、语言、功能开关和配置版本;
  • API、查询缓存、IndexedDB、Service Worker、埋点和错误监控都带租户作用域;
  • 白标通过设计 Token 和受约束的品牌资产实现,不下发任意 CSS/HTML;
  • 功能差异由带版本的 Capability/Feature Flag 描述,不在业务代码散落租户 ID 判断;
  • 前端隔离只是纵深防御,真正的数据授权和租户过滤必须由服务端完成。

一、先明确租户模型

常见入口模式:

模式示例特点
子域名acme.example.com品牌清晰,需要域名和证书管理
路径example.com/t/acme部署简单,路由和缓存必须带租户
自定义域名portal.acme.com白标体验最好,需要域名验证
登录后选择一个账号进入多个组织切换租户时必须清理状态和缓存

需要进一步确认:一个用户能否属于多个租户、是否允许跨租户聚合视图、租户能否定制流程、数据驻留地区是否不同。

二、总体架构

Bootstrap 契约

tenant-bootstrap.ts
interface TenantContext {
tenantId: string;
tenantSlug: string;
userId: string;
membershipId: string;
roles: string[];
permissions: string[];
capabilities: Record<string, boolean>;
locale: string;
timezone: string;
configVersion: string;
themeVersion: string;
region: string;
}

interface BootstrapResponse {
context: TenantContext;
theme: TenantTheme;
navigation: NavigationItem[];
expiresAt: number;
}

TenantContext 在一次租户会话中保持不可变。切换租户应当像切换账号一样重新 Bootstrap,而不是只修改一个全局 tenantId

三、租户解析与信任边界

推荐流程:

  1. 网关根据已验证域名或路径得到候选租户;
  2. 认证服务确认当前用户属于该租户;
  3. 服务端签发带租户范围的短期会话;
  4. Bootstrap 返回前端展示所需的租户上下文;
  5. 后续请求由会话声明和服务端资源归属共同授权。
前端传入 tenantId 不能作为授权依据

攻击者可以修改 Header、URL、请求体和本地状态。服务端必须从已验证会话获取租户范围,并在查询、缓存和对象存储签名中强制隔离。

四、租户作用域数据层

所有缓存键都必须显式包含租户:

tenant-query-key.ts
type QueryKeyPart = string | number | boolean;

function tenantQueryKey(
tenantId: string,
resource: string,
...parts: QueryKeyPart[]
): readonly QueryKeyPart[] {
return ['tenant', tenantId, resource, ...parts] as const;
}

const projectKey = tenantQueryKey(context.tenantId, 'projects', page, filter);

切换租户时执行完整边界重置:

  • 取消仍在飞行中的旧租户请求;
  • 清除或分区内存查询缓存和状态管理;
  • 关闭旧租户 WebSocket/SSE,并用新作用域重连;
  • IndexedDB 数据库名或对象键带租户;
  • 清理剪贴板式临时状态、上传队列和草稿;
  • 更新监控 Scope 后再渲染新租户页面。

Service Worker 的 Cache Storage 也要考虑租户。包含用户数据的 HTML 或 API 响应默认不进入共享公共缓存。

五、白标与主题系统

租户可配置内容应是受约束的 Schema:

tenant-theme.ts
interface TenantTheme {
brandName: string;
logoUrl: string;
faviconUrl?: string;
tokens: {
primaryColor: string;
fontFamily: 'system' | 'inter' | 'source-sans';
borderRadius: 'sm' | 'md' | 'lg';
};
}

不要允许租户直接注入任意 CSS、HTML 或脚本。主题发布前校验:

  • 颜色格式、对比度和可访问性;
  • 图片域名、MIME、尺寸和安全扫描;
  • 字体许可证、加载性能和跨域策略;
  • Token 是否完整,未知字段是否拒绝;
  • 主题版本能否快速回滚。

六、权限、能力与定制边界

需要区分三个概念:

概念回答的问题示例
Permission当前用户能否做invoice.approve
Capability当前租户是否拥有功能advanced-reporting
Configuration功能如何表现默认币种、审批级数

不要在代码中散落:

anti-pattern.ts
// 错误示例:租户特例会不断扩散
if (tenantId === 'acme' || tenantId === 'globex') {
showAdvancedReport();
}

应该由稳定的能力名和配置 Schema 驱动,并在服务端同步校验。前端隐藏按钮只改善体验,不构成权限控制。

七、发布与兼容

多租户系统不能假设所有租户同时升级配置和数据:

  • 前端构建版本、API 版本、租户配置版本分别记录;
  • Feature Flag 按租户、用户、地区和版本分桶;
  • 配置 Schema 做向后兼容和迁移;
  • 高价值租户可以进入延迟发布 Ring;
  • 新功能同时定义关闭开关和数据回滚边界;
  • 自定义域名、CDN 和静态资源使用版本化地址。

八、可观测性和审计

日志与指标至少包含:

  • tenantIdmembershipId、发布版本和配置版本;
  • 路由、请求 Trace、Feature Flag 评估结果;
  • 租户级错误率、性能、关键任务成功率;
  • 配置、权限、域名和主题的变更审计;
  • 跨租户访问拒绝和异常查询模式。

租户 ID 不应成为高基数指标的无控制标签。大规模租户可以在日志中保留 ID,指标按套餐、地区或租户等级聚合,必要时对重点租户单独建视图。

九、测试策略

建立代表性租户矩阵:

  • 默认配置租户;
  • 最大权限和最小权限租户;
  • 自定义域名与白标租户;
  • 旧配置版本租户;
  • 大数据量和多地区租户;
  • 一个用户属于多个租户的切换场景。

安全测试必须直接构造跨租户资源 ID 请求,验证服务端拒绝,而不是只测试 UI 是否隐藏。

十、演进路线

  1. 初期:共享部署、统一 Schema、逻辑隔离;
  2. 增长期:租户级配置中心、灰度、监控和成本核算;
  3. 企业阶段:独立域名、数据驻留、租户专属密钥或部署单元;
  4. 平台阶段:受治理的扩展点、Marketplace 和租户自助配置。

常见面试问题

Q1: 多租户前端最大的安全风险是什么?

答案:缓存、全局状态或请求上下文残留造成跨租户数据泄露。前端必须分区并在切换时重置,但最终应由服务端基于会话和资源归属强制授权。

Q2: 为什么切换租户不能只更新 tenantId?

答案:旧请求、WebSocket、查询缓存、草稿、上传任务和监控上下文都可能继续使用旧作用域。应取消异步任务并重建完整租户会话边界。

Q3: 每个租户是否应该单独构建一份前端?

答案:通常不应该,会导致版本碎片和发布成本失控。优先使用统一构建、运行时配置和设计 Token;只有强合规、独立部署或完全不同产品线才考虑独立构建。

Q4: 如何避免功能开关变成大量 if-else?

答案:使用稳定 Capability 名称、集中评估层和清晰的模块边界。临时发布开关还必须有负责人和清理日期,长期套餐能力则作为正式领域模型维护。

Q5: 白标系统为什么不能开放任意 CSS?

答案:任意 CSS 会破坏布局、可访问性和升级兼容,也可能借助资源 URL 泄露信息。受约束的 Token Schema 更容易校验、预览、版本化和回滚。

Q6: 如何定位只影响一个租户的问题?

答案:对比该租户与健康租户的配置版本、能力集合、数据量、地区和发布 Ring;通过租户维度日志关联请求 Trace,并在脱敏的配置快照上复现。

相关链接