设计多租户 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 契约
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。
三、租户解析与信任边界
推荐流程:
- 网关根据已验证域名或路径得到候选租户;
- 认证服务确认当前用户属于该租户;
- 服务端签发带租户范围的短期会话;
- Bootstrap 返回前端展示所需的租户上下文;
- 后续请求由会话声明和服务端资源归属共同授权。
攻击者可以修改 Header、URL、请求体和本地状态。服务端必须从已验证会话获取租户范围,并在查询、缓存和对象存储签名中强制隔离。
四、租户作用域数据层
所有缓存键都必须显式包含租户:
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:
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 | 功能如何表现 | 默认币种、审批级数 |
不要在代码中散落:
// 错误示例:租户特例会不断扩散
if (tenantId === 'acme' || tenantId === 'globex') {
showAdvancedReport();
}
应该由稳定的能力名和配置 Schema 驱动,并在服务端同步校验。前端隐藏按钮只改善体验,不构成权限控制。
七、发布与兼容
多租户系统不能假设所有租户同时升级配置和数据:
- 前端构建版本、API 版本、租户配置版本分别记录;
- Feature Flag 按租户、用户、地区和版本分桶;
- 配置 Schema 做向后兼容和迁移;
- 高价值租户可以进入延迟发布 Ring;
- 新功能同时定义关闭开关和数据回滚边界;
- 自定义域名、CDN 和静态资源使用版本化地址。
八、可观测性和审计
日志与指标至少包含:
tenantId、membershipId、发布版本和配置版本;- 路由、请求 Trace、Feature Flag 评估结果;
- 租户级错误率、性能、关键任务成功率;
- 配置、权限、域名和主题的变更审计;
- 跨租户访问拒绝和异常查询模式。
租户 ID 不应成为高基数指标的无控制标签。大规模租户可以在日志中保留 ID,指标按套餐、地区或租户等级聚合,必要时对重点租户单独建视图。
九、测试策略
建立代表性租户矩阵:
- 默认配置租户;
- 最大权限和最小权限租户;
- 自定义域名与白标租户;
- 旧配置版本租户;
- 大数据量和多地区租户;
- 一个用户属于多个租户的切换场景。
安全测试必须直接构造跨租户资源 ID 请求,验证服务端拒绝,而不是只测试 UI 是否隐藏。
十、演进路线
- 初期:共享部署、统一 Schema、逻辑隔离;
- 增长期:租户级配置中心、灰度、监控和成本核算;
- 企业阶段:独立域名、数据驻留、租户专属密钥或部署单元;
- 平台阶段:受治理的扩展点、Marketplace 和租户自助配置。
常见面试问题
Q1: 多租户前端最大的安全风险是什么?
答案:缓存、全局状态或请求上下文残留造成跨租户数据泄露。前端必须分区并在切换时重置,但最终应由服务端基于会话和资源归属强制授权。
Q2: 为什么切换租户不能只更新 tenantId?
答案:旧请求、WebSocket、查询缓存、草稿、上传任务和监控上下文都可能继续使用旧作用域。应取消异步任务并重建完整租户会话边界。
Q3: 每个租户是否应该单独构建一份前端?
答案:通常不应该,会导致版本碎片和发布成本失控。优先使用统一构建、运行时配置和设计 Token;只有强合规、独立部署或完全不同产品线才考虑独立构建。
Q4: 如何避免功能开关变成大量 if-else?
答案:使用稳定 Capability 名称、集中评估层和清晰的模块边界。临时发布开关还必须有负责人和清理日期,长期套餐能力则作为正式领域模型维护。
Q5: 白标系统为什么不能开放任意 CSS?
答案:任意 CSS 会破坏布局、可访问性和升级兼容,也可能借助资源 URL 泄露信息。受约束的 Token Schema 更容易校验、预览、版本化和回滚。
Q6: 如何定位只影响一个租户的问题?
答案:对比该租户与健康租户的配置版本、能力集合、数据量、地区和发布 Ring;通过租户维度日志关联请求 Trace,并在脱敏的配置快照上复现。