Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-004-tenant-rbac.zh-CN.md
T
1445043649 89fe54cc07 docs(multi-tenant): 加入汇总索引、跨文档一致性修订、Postgres 切换前移到 Stage 0
* 新增 README.zh-CN.md 汇总索引:ADR 状态表 + Stage 0-4 业务目标 / 技术路径 /
  验证方式 + Stage↔ADR 对照矩阵 + 不可逆决策一览 + 用语映射 + FAQ + 阅读路径
* 新增 workspace-schema-design.zh-CN.md(Stage 0 schema 锁定版)一并入库
* 7 份 ADR 顶部加"代码命名"映射行(tenant_id ↔ workspace_id)
* ADR-002 §1 加分期落地提示,明确 K8s 推迟到 Stage 3
* ADR-005 §5"第 1/2 阶段"补出与 rollout Stage 2/3 的映射
* ADR-007 §4 加 /api/v1/ 反向链接;§8 加 tid → wid 字段名映射
* headless-api §0/§7 把"SaaS + on-prem 双主线"改为"SaaS 主线、schema 兼容 on-prem"
* phased-rollout 去除重复的"Go/No-Go 进入 Stage 2"段

Postgres 切换从 Stage 1 提前到 Stage 0:Stage 0 已要 ALTER 4 张表加
workspace_id,先 SQLite 再 PG 是纯返工;Stage 0 没有生产数据,迁移阻力最小。
同步调整 phased-rollout / headless-api / phase-0-plan / workspace-schema-design /
README 中的时间盒(Stage 0: 3-4→4-5 周;Stage 1: 10-15→8-13 周)、不可逆决策
清单、PR 顺序、轨道前置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:39:58 +08:00

375 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-004 · 租户内层级与 RBAC
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 产品 + 后端 lead |
| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) — 现状:`users.token_version` + JWT `ver` claim 已存在;`@require_permission` 装饰器存在但 `owner_check` 是 bool 需扩为 enum`MembershipCache` 30s LRU 全新建 |
| 代码命名 | 本 ADR 写 `tenant_id` / `tenant_memberships`,落代码读作 `workspace_id` / `workspace_memberships`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
## 1. 背景
DeerFlow 当前的角色模型在 `users.system_role` 字段(`backend/packages/harness/deerflow/persistence/user/model.py:33`),只有两级:
- `admin`:首启账户、平台运维
- `user`:普通注册用户
并且 admin 是**全平台级别**的——它能管所有用户。多租户化后,"管理员"概念要拆成两层:
- **平台 admin**:你(运营 DeerFlow SaaS 的人)的运维账号
- **租户内角色**:客户公司内部的角色(owner / admin / member
需要决定:**租户内要不要分角色?分多细?**
---
## 2. 决策
**采用二级 RBAC**:每个租户内有 `owner` / `admin` / `member` 三种角色。最小化但够用。
| 角色 | 数量 | 核心权限 |
|---|---|---|
| **owner** | 1 个/租户(可转让) | 删除租户、修改计费、转让所有权、管理 admin |
| **admin** | 多个 | 邀请/移除 member、安装/启用 skill、配置 MCP、查看用量 |
| **member** | 多个 | 跑 agent、上传文件、看自己的 thread、看本租户共享 skill |
---
## 3. 备选方案与拒绝理由
### A. 扁平(租户内不分角色)
**拒绝。** 简单但企业客户会立刻不满:
- 没法满足"我让员工用,但不让他们改 skill 配置"的需求
- 没法做 SSO / SCIM 集成(IT 管理员要能批量管成员)
- 客户拉新时(员工增多)会出现"谁负责清理"的问题
### B. 三级以上 RBAC(自定义角色 / 项目级权限)
**拒绝(起步阶段)。** 复杂度爆炸:
- 自定义角色 = 完整的 RBAC engine(角色继承、权限矩阵、UI 编辑器)
- 项目/工作区级权限 = 还要再加一层组织
- 这是 Enterprise 后期需求,第一年用不到
**保留作为后续扩展点**:等"自定义角色"成为销售卡点再做。
### C. 单一 adminowner = admin = 同一个)
**拒绝。** owner 和 admin 必须分开:
- owner 是"账户所有权"——负责计费和租户存亡
- admin 是"日常管理"——可以授权多个
- 不分开会导致"所有 admin 都能删租户",运营失控
---
## 4. 权限矩阵
按"资源 × 动作"展开,下表是 MVP 矩阵(可扩展):
| 资源 | 动作 | owner | admin | member |
|---|---|---|---|---|
| **tenant** | view | ✓ | ✓ | ✓ |
| | update_settings | ✓ | ✓ | |
| | delete | ✓ | | |
| | transfer_ownership | ✓ | | |
| **billing** | view | ✓ | ✓ | |
| | update_payment | ✓ | | |
| | manage_byo_key | ✓ | ✓ | |
| **members** | invite | ✓ | ✓ | |
| | remove | ✓ | ✓ | |
| | change_role | ✓ | | |
| **threads** | create | ✓ | ✓ | ✓ |
| | read_own | ✓ | ✓ | ✓ |
| | read_others | ✓ | ✓ | |
| | delete_own | ✓ | ✓ | ✓ |
| | delete_others | ✓ | ✓ | |
| **skills** | install | ✓ | ✓ | |
| | enable_disable | ✓ | ✓ | |
| | use | ✓ | ✓ | ✓ |
| **mcp_servers** | configure | ✓ | ✓ | |
| | use | ✓ | ✓ | ✓ |
| **custom_agents** | create_for_self | ✓ | ✓ | ✓ |
| | create_shared | ✓ | ✓ | |
| | edit_others | ✓ | ✓ | |
| **usage_reports** | view | ✓ | ✓ | |
| **audit_log** | view | ✓ | ✓ | |
注意几个设计选择:
- **`threads:read_others`**:默认 admin 能看(合规审计需要),但建议加配置项让租户 owner 可关掉
- **`custom_agents:create_for_self` 给 member**:每个成员可以建私人 agent,但要 admin 才能"共享给整租户"
- **`skills:use` 给 member**:使用 admin 启用的 skill;不能自己装 / 关
---
## 5. 落地影响
### 5.1 数据模型(ADR-005 phase 0 plan 已含)
```sql
-- 1 个用户可属于多个租户
tenant_memberships (
tenant_id UUID FK,
user_id UUID FK,
role VARCHAR(16), -- 'owner' | 'admin' | 'member'
invited_by UUID,
joined_at TIMESTAMP,
PRIMARY KEY (tenant_id, user_id)
)
-- 1 个租户必须有且仅有 1 个 owner
CREATE UNIQUE INDEX idx_one_owner_per_tenant
ON tenant_memberships (tenant_id) WHERE role = 'owner';
-- 当前用户的"默认租户"(用户登录时落进哪个 tenant context
ALTER TABLE users ADD COLUMN default_tenant_id UUID;
```
### 5.2 JWT Payload 改造
当前 JWT 只有 `sub: user_id`。改造后:
```json
{
"sub": "<user_id>",
"tid": "<tenant_id>", // 当前激活的租户
"role": "admin", // 当前租户内的角色
"tv": 5, // token_version(保留现有撤销机制)
"iat": ...,
"exp": ...
}
```
**为什么把 role 放 JWT**:避免每次请求都查 `tenant_memberships` 表;权限变化时通过 `token_version++` 强制踢人重登。
**租户切换**:用户在 UI 切换租户时,调 `POST /api/v1/auth/switch-tenant` → 服务端校验 membership → 重发 JWT(新 tid + role+ 新 cookie。
#### 5.2.1 token_version 的存储与失效路径
`token_version` 不是 JWT 自带字段——需要在 `users` 表加一列:
```sql
ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0;
```
签发 JWT 时把当前 `token_version` 写进 `tv` claim;验证 JWT 时**只读一次**(不每请求查 DB):
- 命中**短 cache**30s LRUkey=user_id)→ 比对 cache 里的 token_version
- cache miss → 查 DB 一次,写入 cache
- DB 里的 `token_version` 与 JWT 内 `tv` 不一致 → 401,cookie 强制失效,重登
谁会 bump `token_version`
| 触发 | 谁写 |
|---|---|
| Membership 撤销 | `DELETE /tenants/{tid}/members/{uid}``users.token_version += 1` |
| 角色变更(admin → member 等) | 同上 |
| 主动注销所有设备("踢出所有会话") | `POST /auth/sign-out-all` |
| 密码修改 | `POST /auth/change-password` |
| owner 转让冷静期满 | 双方都 bump |
#### 5.2.2 JWT 内 role 与每请求查 DB 的取舍
这两条**只能选一条**,本 ADR 选 **AJWT 内 role + 30s cache + 敏感操作必查 DB**
- **常规请求**read thread、list skills 等):信 JWT,30s 内可能拿到过期权限——可接受
- **写敏感资源**(删除 thread、修改 billing、邀请成员、安装 skill):装饰器 `@require_permission(strict=True)` 强制查 `tenant_memberships`,不走 cache
- **撤销/降权后**bump `token_version` 让 cache miss → 下一次请求 401 → 用户重登拿新 JWT
这等于"读路径乐观、写路径悲观",与 ADR-001 RLS 是双层兜底(JWT role 错了 RLS 还在 tenant 维度过滤,跨租户绝不会泄露)。
#### 5.2.3 cache 实现要点
```python
# packages/harness/deerflow/persistence/membership/cache.py
class MembershipCache:
"""Per-process LRU, 30s TTL, key = (user_id,) → token_version."""
_cache: TTLCache = TTLCache(maxsize=10_000, ttl=30)
async def get_token_version(self, user_id: str) -> int:
cached = self._cache.get(user_id)
if cached is not None:
return cached
version = await user_repo.get_token_version(user_id)
self._cache[user_id] = version
return version
def invalidate(self, user_id: str) -> None:
"""主动失效——bump token_version 时调用。"""
self._cache.pop(user_id, None)
```
进程间不共享(K8s 多 pod 时 30s 内可能不一致——可接受,超过 30s 全部 pod 自动同步)。
**禁忌**:把 membership cache 做到 Redis 之类共享层。理由:失效广播复杂、租户隔离混乱、收益小。30s 不一致窗口比这个复杂度划算。
### 5.3 AuthMiddleware 注入两层 ContextVar
```python
async def dispatch(request, call_next):
if _is_public(request.url.path):
return await call_next(request)
user = await get_current_user_from_request(request)
jwt_payload = await verify_jwt(request)
tenant_id = jwt_payload["tid"]
role = jwt_payload["role"]
jwt_tv = jwt_payload["tv"]
# 走 cache 比对 token_version:通常不查 DB
current_tv = await membership_cache.get_token_version(user.id)
if jwt_tv != current_tv:
raise HTTPException(401, "Token revoked, please sign in again")
# ContextVar + request.state 注入
set_current_user(user)
set_current_tenant(tenant_id, role)
request.state.user = user
request.state.tenant_id = tenant_id
request.state.role = role
return await call_next(request)
```
**这里**只比对 `token_version`30s cache),**不**每请求查 `tenant_memberships`。membership 状态变化通过 §5.2.1 的 bump 机制传导。
敏感写操作另走一层(`@require_permission(strict=True)`)—— 在路由 handler 里查 DB,详见 §5.2.2。
### 5.4 权限装饰器升级
现有 `@require_permission("threads", "read", owner_check=True)` 沿用,但语义升级:
```python
@router.get("/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check="self_or_admin")
# self_or_admin: 自己拥有的 thread 自由读;他人的需要 admin/owner
async def get_thread(thread_id: str, request: Request):
...
@router.delete("/tenants/{tid}/members/{uid}")
@require_auth
@require_permission("members", "remove", owner_check="admin_only", strict=True)
# strict=True: 不走 cache,强制查最新 membership
async def remove_member(...):
...
```
`owner_check` 的几种模式:
- `"self"`:必须是该资源的 user_id 创建者
- `"self_or_admin"`:自己 / 当前租户的 admin/owner 都行
- `"admin_only"`:仅 admin/owner
- `"owner_only"`:仅 owner
`strict=True` 何时必加(不走 30s cache,强制查 DB):
- 任何修改 RBAC 的操作(邀请/移除/改角色/转让)
- 任何修改计费 / billing 的操作
- 删除 thread / 删除 skill / 删除 MCP server
- owner 才能做的危险操作(删租户)
读操作和普通写操作(创建 thread、改自己的 memory)不需要 strict——cache 不一致最多窗口 30s,足够。
### 5.5 邀请流程
```
1. admin 在 UI 输入 email + role → POST /api/v1/tenants/{tid}/invitations
2. 后端写 invitations 表 → 发邮件(链接含 invitation_token
3. 受邀用户点击:
a. 已注册 → 直接 attach membership
b. 未注册 → 跳注册流程,注册成功后 attach
4. 邀请 7 天过期,admin 可重发或撤销
```
`invitations` 表已在 phase 0 plan 设计中。
### 5.6 Owner 转让
owner 是单一的,转让流程要谨慎:
```
1. owner 在 UI 选定新 owner(必须是当前 admin
2. 系统给原 owner 发确认邮件 + 二次密码确认
3. 24h 冷静期内可撤销
4. 冷静期满 → membership 表事务交换:
原 owner.role := 'admin'
新 owner.role := 'owner'
(单事务 + 唯一索引保证不会出现 2 个 owner)
```
### 5.7 SSO / SCIM 预留
**SSOSAML / OIDC**
- 复用现有 `oauth_provider` / `oauth_id` 字段(`UserRow`
- 新增 `tenant_sso_configs(tenant_id, provider, idp_url, cert, ...)`
- 用户首次 SSO 登录时,自动 attach 到 IdP 配置的默认 tenant + 默认角色(通常 member
**SCIM**(自动用户配置 / 撤销):
- 实现 `/api/scim/v2/Users` + `/api/scim/v2/Groups` 标准接口
- IdPOkta / Azure ADpush 用户增删 → 自动同步 `tenant_memberships`
- **第一年可不实现**——SSO 已能覆盖大多数企业需求
---
## 6. 与现有 system_role 的关系
| 字段 | 含义 | 是否保留 |
|---|---|---|
| `users.system_role` | **平台级**角色:`platform_admin` / `user` | 保留 |
| `tenant_memberships.role` | **租户内**角色:`owner` / `admin` / `member` | 新增 |
`platform_admin`(DeerFlow 运营人员)可以跨租户操作(结合 ADR-001 的 `BYPASSRLS` role),但操作必须审计。普通用户的 `system_role = 'user'`
注意 **首次启动**逻辑改动:
- 旧:`/setup` 创建第一个 admin 用户
- 新:`/setup` 创建第一个 `platform_admin` + 同时建一个名为 `default` 的租户,把这个用户设为 owner(兼容老部署)
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 最后一个 owner 离职导致租户失控 | 不允许 owner 直接退出,必须先转让;提供平台 admin 强制转让的运维接口(带审计) |
| 权限矩阵越改越复杂 | MVP 矩阵冻结半年;新需求先走"是否扁平角色能解决"评审 |
| JWT 里 role 缓存与 DB 不一致 | membership 撤销时 bump `token_version`cookie 失效 |
| 跨租户用户切换场景容易误操作 | UI 显著标识当前激活租户(顶栏色块 + 租户名);危险操作再校验 |
| SSO 接入工作量被低估 | SSO 列入 v2 路线图,明确不阻塞 v1 上线 |
---
## 8. 推翻条件
- **走向自定义角色**:销售反馈明确"我们大客户必须自定义 admin/operator/auditor"——升级到完整 RBAC engine
- **走向项目/工作区**:客户内部需要"多个团队互相不可见"——加 workspace 层(tenant > workspace > user
- **退回扁平**:极简产品形态变化,弃用企业销售路线(不太可能)
---
## 9. 默认假设
| 项 | 默认 |
|---|---|
| 角色数 | 3 (owner/admin/member) |
| Owner 数 | 严格 1 个/租户 |
| Admin 数 | 不限(按 plan 可设上限) |
| 跨租户成员 | 同一 user 可属于多个租户 |
| JWT role claim | 缓存到 token 里,membership 变更走 token_version 失效 |
| 默认新成员角色 | `member` |
| Invitation TTL | 7 天 |
| Owner 转让冷静期 | 24h |
| SSO | v2 路线图(非 v1 阻塞) |
| SCIM | 暂不实现 |
| 自定义角色 | 暂不实现 |