多用户隔离与 `grant` 授权机制:四棵目录树和一个三态字段
一个本来为单人设计的工具要长出多用户能力,最容易出事的地方不是登录,而是登录之后:这个人能看见谁的文件、能调哪些工具、能不能让服务器替他执行东西。DeepTutor 把这件事收在 deeptutor/multi_user/ 一个包里——按我们采集时的统计,这个包只有 14 个 .py 文件、2031 行——比同批读过的 deeptutor/partners(30 个文件、12252 行)、deeptutor/api(40 个文件、15796 行)都小得多,但它管的全是「边界」。
以下行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你 clone 之后行号可能已经漂了,文件名和字段名是稳的,照着找即可。
一、先看包 docstring 划的那条线
deeptutor/multi_user/__init__.py:9-12 把「受支持的多用户路径」写得很明确:默认的 JSON/SQLite 后端,每用户工作区在 data/users/<uid>/,账号与授权在 data/system/,每用户一个独立的 SQLite session DB,鉴权用 JWT。
紧接着 :13-18 是本篇最该先看的一段,因为它是仓库自述的能力边界:PocketBase 模式目前是单用户 only。理由写在同一段里,两条——PocketBase 的 users collection 默认没有 role 字段(所有登录都解析成 role="user",因而无法产生 admin),以及 sessions / messages / turns 的查询没有按 user_id 过滤。docstring 的原话要求是:把 PocketBase 部署当作单用户看待,直到 schema 与查询被更新。
这段值得单独拎出来,是因为它决定了「这套隔离在你的部署里到底成不成立」。如果你把 PocketBase 接上了,上面那套 data/users/<uid> 的隔离叙事就不适用于你——这不是我们的推断,是包 docstring 自己写的。
二、四棵目录树:admin 不住在 data/users/ 里
deeptutor/multi_user/paths.py:4-11 的模块 docstring 直接画出了目录布局,一共四棵树,全在 <runtime-home>/data 之下:
| 路径 | 归谁 |
|---|---|
data/user | admin 工作区(admin scope 的 root 是 data/) |
data/users/<uid> | 每个非 admin 用户一个工作区 |
data/partners/<id> | partner(合成用户)工作区 |
data/system | 部署级状态:账号、grant、审计、每 owner 的密钥 |
第一处反直觉在这张表里:admin 不落在 data/users/<uid>。paths.py:102-106 的 scope_for_user(..., is_admin=True) 直接返回 admin scope,根本不去拼 data/users/<uid> 那条路径。所以你去 data/users/ 下面找管理员的目录是找不到的,管理员那份在 data/user(注意是单数)。这两个目录名只差一个字母,排查数据去向时很容易看串。
同一段 docstring 还写了 data/system 的一条硬约定:从不挂载进 sandbox runner。账号、授权、审计、密钥这四类东西被有意排除在那个执行环境之外。
数据模型对应地也只有三个(deeptutor/multi_user/models.py:36-87):UserRecord(id / username / role / created_at / disabled / avatar)、UserScope(kind / user_id / root)、CurrentUser(id / username / role / scope)。角色只有两种,Role = Literal["admin", "user"],scope 的 kind 同样只有 admin / user(models.py:32-33)。没有中间角色,没有组,没有细分权限位——细粒度全压到下一节的 grant 里。
单机场景的身份是写死的常量:LOCAL_ADMIN_ID = "local-admin"、LOCAL_ADMIN_USERNAME = "local"(models.py:105-106)。当前用户由 ContextVar _current_user 承载,没设置时 get_current_user() 回退到 local_admin_user()(deeptutor/multi_user/context.py:117、128-129)。也就是说,在没开鉴权的部署里,所有代码路径拿到的都是那个本地 admin,这一整套隔离机制在那种部署下等于不参与工作。
从旧版本升上来的部署会撞上一次性迁移:paths.py:48-86 把 pre-v1.5 的同级 multi-user/ 树迁进 data/(multi-user/_system → data/system,其余子目录 → data/users/<uid>)。这段的两个行为要记住:已存在的目标从不覆盖,以及残留项只打 warning 让运维手工对账。它不会替你合并,也不会替你删。
三、grant:一个文件、一组三态字段
授权本身落在 data/system/grants/<user_id>.json,一个用户一个文件(deeptutor/multi_user/grants.py:13、55-57)。v2 结构的字段在 grants.py:16-44:version=2、user_id、models.llm、knowledge_bases、skills、partners、enabled_tools、mcp_tools、cli_apps、exec_enabled。
本篇真正反直觉的那一处在这里:同一个 None,在不同字段上语义正好相反。
grants.py:28-43 的注释把三态说清楚了:对内置工具 enabled_tools,None 表示「默认」,也就是池内全部;[] 表示无;列表表示白名单。而对 mcp_tools 与 cli_apps,对非 admin 是 deny-by-default——None 即无权限,必须管理员显式点名。
也就是说,你打开一份 grant 文件,看到 enabled_tools: null 和 mcp_tools: null 两行长得一模一样,前者是”全放开”,后者是”全关掉”。按直觉读这份 JSON 一定会读反。
落到实操上,两种缺省方向的差别是:内置工具池是”给了再收”,mcp_tools 与 cli_apps 是”不点名就没有”。这两项在 grant 文件里保持 null,等价于管理员什么都没放开——想让某个用户用上,得把名字一条条写进数组。
exec_enabled 同样是三态,覆盖在部署级 exec 策略之上,注释写明 True 只在 sandbox 能做 SYSTEM 级隔离时才被采纳。这一条把授权与执行环境的隔离能力绑在了一起:写了 True 不代表一定生效,还要看那一侧的隔离级别够不够。
grant 只能给非 admin。grants.py:105-106 的 save_grant() 对 admin 直接抛 ValueError("Admin users use the main workspace and cannot receive assignments.")。配合上一节 admin 不住 data/users/ 那条,这两处是同一个设计的两面:admin 用主工作区,不走分配这套。
还有一处防呆值得抄走:validate_grant() 会拒绝 grant 里出现秘密与路径类字段,禁用键集合是 {"api_key", "secret", "password", "token", "path", "base_url"},外加任何以 _key 结尾的键(grants.py:115-133)。所以 grant 文件里不应该出现任何密钥——如果你在自己的部署里看到了,那说明它是从别处塞进去的。
v1 到 v2 的归一在 grants.py:60-66:models.embedding、models.search、spaces 三项因为没有运行时消费者被直接丢弃。升级上来发现这几项不见了,是这一行干的。
四、grant 写在文件里,执行点却分散在四处
一份 JSON 不会自己生效。tool_access.py:14-29 的 docstring 把四个执行点逐条列了出来,这段是排查「我明明分配了却没生效 / 明明没分配却能用」时最该先读的:
allowed_optional_tools:由 turn_runtime 每一轮过滤 tools 载荷,同时 tools router 过滤列表;allowed_mcp_tools:由 chat pipeline 与调用方的mcp_tools_filter求交之后,才去建 deferred-tool loader;allowed_cli_apps:与账号自身的启用/禁用偏好求交;exec_override:叠加在部署级 exec 策略之上。
注意后三条里的”求交”和”叠加”。grant 给了不等于能用——它还要和调用方过滤器、账号自身偏好、部署策略取交集。所以出现「分配了但用不了」时,光看 grant 文件是判断不出来的,得沿着上面这四个点分别看。
其余几类资源各有自己的收口:知识库用前缀区分归属,ADMIN_PREFIX = "admin:kb:"、USER_PREFIX = "user:kb:"(deeptutor/multi_user/knowledge_access.py:19-20)。partner 的授权是只读性质:deeptutor/multi_user/partner_access.py:1-13、37-47 写明非 admin 被分配后可以看到它、把它连成 subagent、在 chat 里 consult,但 consult 仍然在 partner 自己的 scope 里运行;未分配则返回 403 "Partner is not assigned to you"。
有一类资源不参与 grant:owner-bound 的模型档案(deeptutor/multi_user/personal_models.py:1-24)。docstring 给的理由是它绑定的是某个人的订阅而非可计费的团队 key,今天的具体形态是 OpenAI Codex OAuth 登录。对应的做法是:普通用户自己登录后,档案写进该用户自己的 data/users/<uid>/settings/model_catalog.json,从不写入共享的 admin catalog。
五、审计与那个自己承认的竞态
审计在 deeptutor/multi_user/audit.py:log_usage() 记录普通用户对 admin 策展资源的访问(:19-26),admin 自访问是故意不记录的;log_admin_action() 记录 admin 侧的写操作(:29-54、57-67)。落盘位置是 data/system/audit/usage.jsonl(audit.py:16)。
这里有一条取舍要照实说:审计绝不能让请求失败,异常被吞掉。所以 usage.jsonl 缺记录不代表访问没发生,别拿它当强证据用。
另一处仓库自己承认的边界在 deeptutor/multi_user/identity.py:20-25:首个注册用户被提权为 admin 存在竞态,代码用 threading.Lock 串行化 users.json 的写入,但 docstring 承认多 worker 部署仍然会 race,必须依赖外部用户存储。身份文件位置在 identity.py:27-31:data/system/auth/users.json 与 data/system/auth/auth_secret,另有两个 legacy 路径 data/user/auth_users.json、data/user/auth_secret。
六、想自己核一遍,做这几件事
这套东西全在文本里,不用跑起来就能核:
- 读那两段边界声明。 打开
deeptutor/multi_user/__init__.py,看:13-18的 PocketBase 段;打开identity.py,看:20-25的多 worker 段。这两段决定了后面所有隔离叙事在你的部署里成不成立。 - 比对
None的两种含义。 在grants.py:16-44那段注释里,把enabled_tools与mcp_tools/cli_apps三行并排读一遍,确认它们的缺省语义相反。 - 拿字段清单对前端。 后端 grant v2 的字段与前端
web/features/multi-user/types.ts:1-18的GrantPayload是逐项对齐的,前端注释里也复述了三态语义(null= 默认 /[]= 无 / 数组 = 白名单)。两边任意一侧改了字段,对比这两处最快看出来。 - 看管理端到底只有几个口。 多用户管理端一共 5 条路由,全部
Depends(require_admin):GET /admin/resources、GET|PUT /users/{user_id}/grants、POST /admin/skills/install、GET /users(deeptutor/multi_user/router.py:118、135、141、170、226)。前端多用户切片也只有 3 个文件(api.ts、types.ts、components/GrantEditor.tsx),只打其中三个接口(web/features/multi-user/api.ts:13-48)。这个面积小得出乎意料,正好说明复杂度不在接口数,而在前面那些执行点上。 - 确认自己是不是根本没在多用户模式。 如果部署里鉴权是关的,
get_current_user()会回退到本地 admin(context.py:128-129),本文讲的隔离一条都不会触发。
最后说一句关于「该怎么配」的:本文只写了这些字段各自写在哪一行、缺省是什么语义。至于你的团队该把 mcp_tools、cli_apps、exec_enabled 放到什么程度,取决于你的部署形态与 sandbox 隔离能力,仓库没有给通用值,我们也不给。尤其是 cli_apps 与 exec_enabled 这两项——前者缺省即无权限、要管理员逐条点名,后者的 True 还要看 sandbox 能不能做到 SYSTEM 级隔离——这个决定不该照抄任何一篇文章。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。