多公司数据隔离:一次部署,多租户边界
多公司数据隔离:一次部署,多租户边界
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,访问控制实现)