mini-agent 多租户隔离技术详解:让用户身份贯穿认证、执行与配额
问题不在登录页,而在登录之后
单用户服务常从一个共享密码开始:校验密码,发一个 token,后面的请求只要带着 token 就能继续。这种模型没有错,前提是所有请求都属于同一个人。一旦 Web API 面向不可信用户开放,token 不能再只是“进门凭证”。它必须还原成用户 ID 和角色,并跟着请求走到会话历史、任务工作目录、文件系统 sandbox 和用量统计。
mini-agent 的多租户改造因此没有把“多用户”做成某个前端功能,而是把身份传播做成 WebChannel 的基础设施。目标不是让两个用户看见不同的按钮,而是确保两人的请求在任何一层都没有回到共享默认值。
四张表定义用户边界
用户、会话、邀请码和配额存放在 data/users.db。它没有引入外部身份服务,UserManager 直接负责 SQLite 访问。这是单实例服务的一个刻意取舍:数据模型集中,部署简单,但容量与并发上限也需要在后续运行中观察。
| 表 | 关键字段 | 它解决的问题 |
|---|---|---|
users | id、email、password_hash、role、disabled_at | 邮箱唯一,密码用 argon2id 哈希保存,账号可软封禁。 |
sessions | token、user_id、expires_at、revoked_at | token 是可过期、可吊销的登录态,而不是无法撤回的共享口令。 |
invite_codes | code、max_uses、use_count、expires_at | 注册不是开放入口;邀请码能限制次数和有效期。 |
quota_usage | user_id、bucket、task_calls、query_calls、chat_calls、pi_seconds | 以用户和日期为键记录请求数与执行时长。 |
首启时,服务根据配置创建第一个 admin。此后注册只会创建 user。disabled_at 不为空时,登录被拒绝;管理员封禁账号时,还会通过 revoke_all_user_sessions(user_id) 吊销该用户已有的会话。
邀请码的消费和用户创建放在同一个事务中。下面是把实现压缩后的关键逻辑:
with conn: user_id = insert_user(email, hash_password(password), role="user") changed = execute( """UPDATE invite_codes SET use_count = use_count + 1, used_by = COALESCE(used_by, ?) WHERE code = ? AND use_count < max_uses AND (expires_at IS NULL OR expires_at >= ?)""", (user_id, invite_code, now), ) if changed.rowcount != 1: raise InvalidInvite()先插用户、再消费邀请码并不意味着失败会留下半条用户记录:两步处在同一个事务里,邀请码无效时会一起回滚。并发注册时,也只有成功更新到 use_count 的请求能拿到账号。
请求链先认证,再保留,再计量
多租户模式启动 WebChannel 时,middleware 顺序是 CORS、认证、配额。认证中间件跳过 /auth、/register 等公开端点;其他请求都必须先从 Authorization: Bearer <token> 找到有效 session,再把身份写进 request:
async def auth_middleware(request, handler): if request.path in PUBLIC_PATHS: return await handler(request)
token = extract_bearer_token(request) user = user_manager.validate_session(token) if user is None: return unauthorized()
request["token"] = token request["user"] = {"id": user["id"], "role": user["role"]} return await handler(request)validate_session() 同时检查 revoked_at、disabled_at 和 expires_at。会话接近到期时会滑动续期;因此客户端恢复本地 token 后还会调用 /auth/status,而不是只相信 localStorage。
- 打开请求生命周期图 — 支持暗亮主题切换和 PNG/JPEG/WebP/SVG 导出
角色检查不放在前端。处理少数管理员端点时,服务端读取 request["user"]["role"]:/admin/*、待办、日程和话题能力要求 admin,普通用户得到 403。浏览器把按钮改为可见,或 APK 伪造一个“admin”标签,都改变不了这一判断。
会话隔离要落到线程名
对话上下文是最容易漏掉的共享状态。多租户前,Web 线程可由 token 派生为 web_<token[:16]>。多租户后,同一规则被包进用户命名空间:
def default_thread(user_id: int, token: str) -> str: return f"u{user_id}_web_{token[:16]}"
def thread_for(request) -> str: return active_thread.get(request["user"]["id"], default_thread(...))/history 使用当前用户的线程前缀读取记录,活动话题和日志偏移也按用户 ID 保存。这样,token 轮换不会把不同用户的上下文拼到一起;同一个用户在不同登录会话里也仍然处于自己的命名空间。
这里的原则很朴素:不要接受客户端传来的 user_id 当作查询条件。服务端只从已验证 token 推导用户,再用这个用户 ID 构造线程和过滤条件。
工作区不是目录约定,而是 sandbox 的输入
/query 与 /task 会把不可信文本交给 Pi 执行。即使聊天记录已经隔离,如果所有 Pi 子进程仍在同一目录运行,用户依旧可能通过工具调用触碰其他用户或应用本身的文件。
执行器为多租户请求计算工作目录:
def workspace_for(user_id: int | None) -> Path | None: if user_id is None: return legacy_pi_workspace return Path("data/workspaces") / str(user_id)
async def run_pi(prompt, user_id): cwd = workspace_for(user_id) return PiRpcClient(cwd=cwd, user_workspace=str(cwd), sandbox=config)user_workspace 随后进入 bwrap 参数构造。普通用户能写自己的 data/workspaces/<user_id>,但不会获得其他用户工作区、共享项目目录、博客仓库或应用源码的可写挂载。多租户启动时如果无法使用 bwrap 文件系统约束,服务会 fail closed;不会为了“先跑起来”退化成只靠环境变量的 sandbox。
- 打开用户工作区边界图 — 支持暗亮主题切换和 PNG/JPEG/WebP/SVG 导出
图里的“不可见或不可写”取决于具体挂载策略,但结论不变:工作区路径由服务端身份选择,不能由请求 body 指定。工作区会在保留期后清理,避免旧租户目录永久堆积。
配额要在入口原子预留,在出口补执行时长
配额有两个不同的计量时点。chat_calls、query_calls 和 task_calls 在请求进入 handler 前原子预留;这比“先查当前数量,执行后再加一”更可靠,后者会让并发请求同时穿过同一个上限。
def consume_quota(user_id: int, key: str, limit: int) -> bool: bucket = today_utc() with conn: insert_usage_if_missing(user_id, bucket) changed = execute( f"""UPDATE quota_usage SET {key} = {key} + 1 WHERE user_id = ? AND bucket = ? AND {key} < ?""", (user_id, bucket, limit), ) return changed.rowcount == 1没有预留成功时,middleware 返回 429、{"error":"quota_exceeded","bucket":...} 和 Retry-After。Web Chat 页把它显示为当日额度耗尽;客户端不会自己计数或自行决定放行。
pi_seconds 只能在任务结束后知道,因此走另一条路径:SSE 和普通响应的执行外层记录开始时间,并在 finally 中写入 record_usage(user_id, pi_seconds=...)。客户端中断连接也会进入这段清理逻辑,避免把已消耗的执行时间漏记。它是软上限:达到时拒绝下一次请求,不会中途终止一个已运行的 Pi 进程。
设计边界仍然存在
这套设计把隔离约束集中在后端,但它不是“做完就没有风险”。当前实现是单实例 SQLite 和单机 Pi 执行器;用户数量、并发任务和工作区大小增长后,需要重新评估锁竞争、磁盘清理与执行队列。配额阈值也需要按真实 ECS 的资源和任务形态校准,不能从代码里猜一个永久正确的数字。
更重要的是,sandbox 可用性是运行时事实。测试可以验证 user_id 被传入、挂载列表符合策略、超额请求返回 429;ECS 上仍要持续确认 bwrap 可用、配置没有回退、普通用户看不到共享路径。把这一层保留为部署检查项,才符合 fail-closed 的原意。
服务如何把这些后端边界投射到本地实例、博客 Chat 页和管理员 APK,见工程复盘。
参考资料
- mini-agent 多用户认证、租户隔离与会话管理设计 — 用户、会话、邀请码、配额和线程命名空间的设计来源。
- mini-agent 多租户安全加固设计 —
bwrap文件系统约束与 fail-closed 的安全要求。 - mini-agent UserManager 实现 — SQLite 表、会话校验、邀请码事务和配额原子预留的实现依据。
- mini-agent WebChannel 实现 — 认证与配额中间件、角色守卫和
pi_seconds回写的实现依据。