2026/10/10 19:13:09

多租户到底怎么隔数据?Gauzy 隔离机制的实现真相

多租户到底怎么隔数据?Gauzy 隔离机制的实现真相 多租户到底怎么隔数据Gauzy 隔离机制的实现真相【免费下载链接】ever-gauzyEver® Gauzy™ - Open Business Management Platform (ERP/CRM/HRM/ATS/PM) - https://gauzy.co项目地址: https://gitcode.com/GitHub_Trending/ev/ever-gauzy多租户Multi-Tenancy是任何 SaaS 化业务系统都绕不开的命题几十上百家企业共用一套部署A 公司的发票、员工、项目数据绝不能泄露给 B 公司。开源 ERP/CRM/HRM 平台 Gauzyever-gauzy把这一命题直接做进了框架底层——它不靠业务代码里记得过滤来保证隔离而是让租户隔离成为 ORM 实体、服务基类与请求守卫三层协同的默认行为。本文从仓库源码出发拆解 Gauzy 的租户模型、隔离边界与写入防护并讨论这套机制对二次开发和系统性能的真实影响。多租户数据隔离的三种技术路线业界常见的多租户隔离方案大体分三类独立数据库Database-per-Tenant每个租户一个库物理隔离最彻底、恢复与扩容最灵活但连接池、迁移、备份成本随租户数线性上涨小型团队难以负担共享库 独立 SchemaSchema-per-Tenant同库不同 schema隔离性尚可但跨租户统计与 schema 管理复杂度高且多数 ORM 对动态 schema 切换支持一般共享库 共享表 租户列Shared Table / Row-level Isolation所有租户数据同表靠tenant_id列区分归属。成本最低、运维最简单但隔离完全依赖应用层每次查询都不忘带条件。Gauzy 选择的是第三条路线——共享表 租户列并且把带条件这件事从人的自觉变成了框架的默认。这样做的好处很直接Docker 一键拉起一套 PostgreSQL 就能服务多租户对自托管的中小企业场景最友好代价则是隔离责任全部压在框架层一旦某个查询漏掉租户条件就是全量数据泄露。Gauzy 正是围绕绝不漏掉这一目标设计了下面这套机制。租户模型两级隔离边界Gauzy 的租户隔离是两级的最外层是Tenant租户内层是Organization组织。一个租户下可以有多个组织日常业务数据发票、工时、候选人、任务几乎全部挂在组织之下而用户则直接挂在租户之下。先看租户根实体 Tenant它持有名称、Logo、标准工时等租户级配置并通过一对多关系下挂organizations、rolePermissions角色权限、featureOrganizations功能开关——角色的权限集也是按租户隔离的这意味着 A 租户调整权限不会影响 B 租户。真正起隔离作用的是两个抽象基类。TenantBaseEntity 是所有租户级实体的公共父类它定义了唯一的隔离外键export abstract class TenantBaseEntity extends BaseEntity implements IBasePerTenantEntityModel { MultiORMManyToOne(() Tenant, { nullable: true, onDelete: CASCADE }) tenant?: ITenant; IsUUID() RelationId((t: TenantBaseEntity) t.tenant) ColumnIndex() MultiORMColumn({ nullable: true, relationId: true }) tenantId?: ID; }注意两个细节tenantId上打了ColumnIndex()索引保证按租户过滤的查询走索引onDelete: CASCADE让租户删除时级联清理其全部业务数据。而 TenantOrganizationBaseEntity 在其上再扩展一层organizationId同样带索引与外键级联。用户User直接继承TenantBaseEntity组织Organization继承TenantBaseEntity绝大多数业务实体如 candidate、invoice、task 等 3000 多个核心文件都继承TenantOrganizationBaseEntity。于是整个数据模型天然形成一棵租户 → 组织 → 业务行的归属树。隔离边界从哪来RequestContext 与 JWT有了列条件从哪取答案在 RequestContext。它基于nestjs-cls的 AsyncLocalStorage在请求进入时把当前登录用户放入上下文并提供一系列静态访问器static currentTenantId(): ID | null { const user: IUser | null RequestContext.currentUser(); return user?.tenantId || null; } static currentOrganizationId(): ID | null { const user: IUser | null RequestContext.currentUser(); return user?.lastOrganizationId || null; }这里的用户来自 JWT 策略在请求头解析出的身份tenantId取自用户记录而非请求参数。Gauzy 在 bootstrap 中注册了全局AuthGuard所有路由默认要求登录也就是说没有合法身份的请求根本到不了服务层而服务层拿到的tenantId只来自服务端信任的用户对象。这正是不能信任客户端传参这一安全原则的落地——隔离条件永远由框架从身份推导而不是从 query/body 里读。读路径查询条件自动拼装隔离的最后一公里在 CRUD 服务层。绝大多数业务服务继承 TenantAwareCrudService它的注释写得很直白如果 RequestContext 中有用户就给所有查询过滤器加上 tenantId如果没有用户行为与普通 CrudService 完全一致。以findAll为例每次调用都会先经过findManyWithTenant()把用户身份翻译成查询条件private findConditionsWithTenantByUser(user: IUser): FindOptionsWhereT { return { ...(this.typeOrmRepository.metadata?.hasColumnWithPropertyPath(tenantId) ? { tenant: { id: user.tenantId }, tenantId: user.tenantId } : {}), ...this.findConditionsWithEmployeeByUser() } as FindOptionsWhereT; }这段代码有几个关键设计条件合并而非覆盖调用方传入的where会与租户条件合并findConditionsWithTenant中逐个展开数组形式的 where业务过滤逻辑不被破坏列存在性探测hasColumnWithPropertyPath(tenantId)动态判断实体是否有租户列让全局性实体如公开统计类天然跳过租户过滤员工级再收敛findConditionsWithEmployeeByUser会进一步叠加employeeId过滤——普通员工只能看到自己的数据除非拥有CHANGE_SELECTED_EMPLOYEE权限如管理者查看团队数据。TenantAwareCrudService还为此提供了withoutEmployeeFilter()逃生舱用引用计数的 AsyncLocalStorage 深度控制避免并发请求间的状态串扰。findOne、count、paginate、delete、softDelete等所有读/写查询入口都套用了同一套拼接逻辑保证从任何入口查条件都在。写路径自动打戳与越权防护查询要过滤写入更不能裸奔。create()在落库前会强制给实体盖上租户戳public async create(entity: IPartialEntityT): PromiseT { const tenantId RequestContext.currentTenantId(); await this.assertNotForeignRow(entity, tenantId); await this.assertNestedGraphNotForeign([entity], tenantId); return await super.create({ ...entity, ...(hasTenantColumn ? { tenant: { id: tenantId }, tenantId } : {}), // ...employeeId 逻辑同理 }); }即使调用方在请求体里塞了一个别人的tenantId也会被服务端身份覆盖——客户端永远无法通过构造 payload 把数据写进别的租户。同时TypeORM 的实体事件订阅器TenantOrganizationBaseEntityEventSubscriber会在beforeEntityCreate里根据tenantId/organizationId补齐关联对象保证外键关系完整。写路径上最值得细读的是assertNotForeignRow。它的动机在源码注释里讲得很清楚save()/create()携带 id 时会被 TypeORM 当作 upsert——按主键查、存在就 UPDATE而服务层只会把调用者的 tenantId 盖上去。如果请求体走私了一个其他租户的 id就可能发生改掉并重新归属别人数据的越权写入源码中明确提到了 GHSA-gwpq-mmw7-vx85 / GHSA-x4mv-fhwj-g3rp 这类漏洞类别。该防护在写入前用withDeleted: trueTypeORM/filters: falseMikroORM查一次目标行的真实租户归属不属于当前租户就直接抛ForbiddenException(The record belongs to another tenant)并且对无租户的空行也采取 fail-closed 策略。createMany/saveMany还提供了批量版本assertNotForeignRows一次查询校验所有 idassertNestedGraphNotForeign则把校验延伸到级联子对象堵住通过嵌套关系重新挂靠其他租户数据的旁路。请求层还有一道兜底闸门 TenantBaseGuard它比对请求头tenant-id、query 参数或 JSON body 中声明的租户与当前登录租户是否一致不一致直接拒绝。三处防线请求守卫、服务层过滤、写入防护叠加构成了前端传了也没用、服务层漏不了、写入抢不走的完整闭环。值得一提的还有delete上的谓词守卫assertCriteriaHasPredicate要求调用方必须给出明确条件防止出现delete({ employeeId: undefined })这类因注入的租户条件通过校验、却把整个租户数据误删的极端场景——隔离机制再强也防不住条件为空 全删的 SQL 语义陷阱。双 ORM 适配一套隔离逻辑两套引擎Gauzy 同时支持 TypeORM 与 MikroORM通过DB_ORM环境变量切换上面的基类全部用MultiORM*装饰器MultiORMColumn、MultiORMManyToOne、MultiORMEntity编写仓库中数百个实体均如此声明。这意味着租户隔离逻辑不依赖特定 ORM 的私有 API——TenantAwareCrudService内部对两种 ORM 的查询写法分别适配如上面assertNotForeignRow的 switch 分支上层业务代码完全无感。对二次开发者而言新增实体只要继承对应基类并沿用MultiORM装饰器就能免费获得与核心实体一致的隔离能力无论底层切到哪个 ORM。对二次开发与性能的影响二次开发视角租户隔离被做成了继承即得的默认能力业务侧几乎不需要感知。但这也意味着三条纪律必须遵守第一自定义实体应继承TenantBaseEntity或TenantOrganizationBaseEntity而不是裸继承BaseEntity否则数据将游离于隔离体系之外第二自定义服务应继承TenantAwareCrudService以获得自动过滤与自动打戳确需跨租户操作时显式使用saveWithoutEnrichment之类的逃生通道而不是偷偷绕过第三新增的全局性非租户级实体要能接受hasColumnWithPropertyPath(tenantId)探测自动跳过租户过滤的行为避免误伤。反过来对想深度定制隔离边界比如让某个模块跨组织共享的团队这套基于基类的设计意味着你只需要在该模块的服务里覆盖条件拼装逻辑而不必动全局框架。性能视角共享表方案最大的性能隐患是租户列无索引导致全表扫描。Gauzy 通过两点缓解tenantId/organizationId列上强制ColumnIndex()且过滤条件会同时命中tenantId与organizationId两列查询计划可以走复合索引路径加上外键CASCADE让数据清理随租户删除自动完成避免了孤儿数据长期堆积拖慢扫描。当然这种行级过滤仍会在每个查询上叠加额外谓词租户数量极大、单表数据量极高时可以考虑按租户做表分区partition by tenantId或升级到独立 schema 方案——Gauzy 的实体设计保留了这种演进空间因为隔离外键从第一天就是显式建模的。小结Gauzy 的多租户隔离是一个典型的应用层行级隔离工程范本实体基类定义归属结构RequestContext 从受信任身份推导租户CRUD 基类在读写两端强制拼装与校验请求守卫兜底双 ORM 适配保证一致性。这套机制最大的价值不在于某一行代码而在于把数据隔离从开发者的心智负担中剥离出来变成框架级默认行为——对于打算在开源 ERP 上做多租户二开的团队理解这条链路就等于掌握了这个系统数据安全的地基。【免费下载链接】ever-gauzyEver® Gauzy™ - Open Business Management Platform (ERP/CRM/HRM/ATS/PM) - https://gauzy.co项目地址: https://gitcode.com/GitHub_Trending/ev/ever-gauzy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考