跳转到内容

多公司数据隔离:一次部署,多租户边界

多公司数据隔离:一次部署,多租户边界

1. 问题定义

多租户系统中,不同租户之间的数据必须完全隔离。常见的数据泄漏场景:

  • 查询遗漏 company scope:某个查询忘记加 WHERE company_id = ?,导致返回其他公司数据
  • API Key 跨公司使用:Agent A 的 Key 可以访问公司 B 的资源
  • 聚合查询越界:全局统计查询未限制公司范围,暴露跨公司数据

多公司数据隔离模式通过在数据模型、服务层和路由层三层强制 company scope 来解决这个问题。

2. 隔离层级

3. 数据模型设计

3.1 Company-scoped 实体

所有业务实体表必须包含 company_id 外键:

// 伪代码:Drizzle schema 定义
const agents = pgTable("agents", {
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
name: text("name").notNull(),
adapterType: text("adapter_type"),
// ...
});

关键规则

  • 没有 company_id 的表只能是全局配置表(如 instance_settings
  • 所有公司级实体通过外键强制引用存在性
  • 复合唯一索引 (company_id, name) 防止同一公司内重名

3.2 核心公司级实体

实体隔离要求说明
Agents严格隔离Agent 只属于一个公司
Issues严格隔离任务只属于一个公司
Projects严格隔离项目只属于一个公司
Goals严格隔离目标只属于一个公司
Budgets严格隔离预算只属于一个公司
Secrets严格隔离密钥只属于一个公司
Activity Log严格隔离审计日志按公司查询

4. 路由层自动注入

4.1 身份认证与权限断言

每个 API 请求经过中间件链:

请求 → 身份认证 → 权限断言(Board Claim) → 注入 company_id → 路由处理
  • 身份认证:验证用户/Agent 身份(JWT session 或 API Key)
  • Board Claim:断言当前用户对目标公司有操作权限
  • 自动注入:将 company_id 注入到请求上下文,后续服务层直接使用

4.2 Agent API Key 隔离

Agent 通过 bearer API Key 访问 API:

agent_api_keys 表:
- id, key_hash (bcrypt 哈希)
- agent_id (外键到 agents)
- company_id (冗余字段,用于快速路由)

关键约束:

  • API Key 绑定到特定 Agent,Agent 绑定到特定公司
  • 使用 Agent Key 的请求只能访问该公司资源
  • 查询时通过 agent_id → company_id 链路自动限定范围

5. 服务层公司范围校验

5.1 防御性校验

每个服务方法在操作前校验目标资源属于正确的公司:

// 伪代码:服务层防御校验
async function getIssue(companyId: string, issueId: string) {
const issue = await db.select().from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
if (!issue) {
throw new NotFoundError("Issue not found or access denied");
}
return issue;
}

5.2 批量查询的公司范围

批量查询时,必须按公司过滤:

// 正确做法:一次查询限定在公司范围内
const companyIssues = await db.select().from(issues)
.where(eq(issues.companyId, companyId));
// 错误做法:查询所有 issue 再在代码中过滤(已泄漏数据)
// const allIssues = await db.select().from(issues);

6. 公司导入/导出的数据隔离

6.1 导出时的 Secret 清洗

导出整个公司配置(org、Agent、项目等)时:

  • Secret 值替换为占位符(不导出真实密钥)
  • 公司 ID 重写,避免导入时与现有公司冲突
  • 碰撞处理:名称冲突时自动追加后缀

6.2 导入时的隔离保证

导入公司数据到目标实例:

  • 创建新的 company_id,与源公司完全解耦
  • 所有关联实体使用新的公司 ID
  • 保留原公司内的引用关系(内部一致性)

7. 聚合查询与跨公司操作

7.1 全局仪表盘

需要聚合多公司数据时(如实例级别统计):

  • 使用 instance-level 权限(instance admin role)
  • 查询时按公司分组聚合
  • 不暴露单个公司的敏感数据

7.2 跨公司搜索

搜索功能需要限定在当前用户有权限的公司范围内:

// 伪代码:公司搜索
const userCompanyIds = await getUserCompanyMembership(user.id);
const results = await searchCompanies(query, userCompanyIds);

8. 陷阱与对策

陷阱后果对策
查询遗漏 company_id数据泄漏到其他公司服务层统一封装 forCompany(companyId) 查询构造器
Agent Key 跨公司使用Agent 访问非所属公司资源API Key 解析后自动绑定 company_id,路由层强制校验
外键缺失 company_id无法建立数据隔离Schema review checklist 强制检查所有表的 company_id
聚合查询未分组跨公司数据混合所有聚合查询按 company_id GROUP BY
导入时 ID 冲突覆盖现有数据导入时生成新 ID,使用 collision handling 处理名称冲突

9. 参考来源

  • Paperclip DATABASE.md — Resource membership tables, company-scoped entities
  • Paperclip SPEC-implementation.md — §3 “Company model: Company is first-order; all business entities are company-scoped”
  • Paperclip DB Schema — packages/db/src/schema/(80+ 表,均包含 company_id)
  • Paperclip Access Service — server/src/services/access.ts(223KB,访问控制实现)